Compare commits
34 Commits
0.1.0-alph
...
0.1.4-alph
| Author | SHA1 | Date | |
|---|---|---|---|
| 348dc496a6 | |||
| 09df38a3b2 | |||
| cae96157ca | |||
| 0a7f0475a4 | |||
| 8156d9d046 | |||
| b92d446184 | |||
| 73b8a1a6da | |||
| ba0b377cee | |||
| 0822b46596 | |||
| d93cecbea3 | |||
| 0887970374 | |||
|
|
a53b2181ab | ||
| 81664e3d4e | |||
| 29ec6da872 | |||
| 111ab3b082 | |||
| 1c4806bd92 | |||
| 3f8c02aef8 | |||
| b73babf274 | |||
|
|
bbc778d5ca | ||
| cfa628a8a6 | |||
| 5e1d76bbcd | |||
| 61fb90bd34 | |||
| 50242981a5 | |||
| 85c485a4e4 | |||
| 48af00950a | |||
| 31e94c53c6 | |||
| ae01a2d67a | |||
| 2a5a3b5c0a | |||
| 0c45695edb | |||
| ea5009933c | |||
| 0feb69b72e | |||
| ab7664c872 | |||
| cd6a9f0550 | |||
| ce9193c187 |
1445
Cargo.lock
generated
1445
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
18
Cargo.toml
18
Cargo.toml
@@ -8,19 +8,22 @@ coop = { path = "crates/*" }
|
|||||||
|
|
||||||
# UI
|
# UI
|
||||||
gpui = { git = "https://github.com/zed-industries/zed" }
|
gpui = { git = "https://github.com/zed-industries/zed" }
|
||||||
gpui_tokio = { 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",
|
||||||
"all-nips",
|
"nip96",
|
||||||
|
"nip59",
|
||||||
|
"nip49",
|
||||||
|
"nip44",
|
||||||
|
"nip05",
|
||||||
] }
|
] }
|
||||||
|
|
||||||
smol = "2"
|
smol = "2"
|
||||||
tokio = { version = "1", features = ["full"] }
|
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"
|
||||||
@@ -29,8 +32,9 @@ 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"
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
strip = true
|
strip = true
|
||||||
|
|||||||
@@ -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.0"
|
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
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 |
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "coop"
|
name = "coop"
|
||||||
version = "0.1.0"
|
version = "0.1.4"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
publish = false
|
publish = false
|
||||||
|
|
||||||
@@ -11,14 +11,12 @@ 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
|
||||||
gpui_tokio.workspace = true
|
|
||||||
reqwest_client.workspace = true
|
reqwest_client.workspace = true
|
||||||
|
|
||||||
tokio.workspace = true
|
|
||||||
nostr-connect.workspace = true
|
nostr-connect.workspace = true
|
||||||
nostr-sdk.workspace = true
|
nostr-sdk.workspace = true
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
@@ -27,8 +25,11 @@ serde_json.workspace = true
|
|||||||
itertools.workspace = true
|
itertools.workspace = true
|
||||||
dirs.workspace = true
|
dirs.workspace = true
|
||||||
rust-embed.workspace = true
|
rust-embed.workspace = true
|
||||||
|
log.workspace = true
|
||||||
|
smallvec.workspace = true
|
||||||
smol.workspace = true
|
smol.workspace = true
|
||||||
|
oneshot.workspace = true
|
||||||
|
|
||||||
cargo-packager-updater = "0.2.2"
|
rustls = "0.23.23"
|
||||||
|
futures= "0.3"
|
||||||
tracing-subscriber = { version = "0.3.18", features = ["fmt"] }
|
tracing-subscriber = { version = "0.3.18", features = ["fmt"] }
|
||||||
log = "0.4"
|
|
||||||
|
|||||||
916
crates/app/src/device.rs
Normal file
916
crates/app/src/device.rs
Normal 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 ..."),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,149 +1,127 @@
|
|||||||
|
use anyhow::anyhow;
|
||||||
use asset::Assets;
|
use asset::Assets;
|
||||||
use async_utility::task::spawn;
|
|
||||||
use chats::registry::ChatRegistry;
|
use chats::registry::ChatRegistry;
|
||||||
use common::{
|
use device::Device;
|
||||||
|
use futures::{select, FutureExt};
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
use global::constants::APP_NAME;
|
||||||
|
use global::{
|
||||||
constants::{
|
constants::{
|
||||||
ALL_MESSAGES_SUB_ID, APP_ID, APP_NAME, FAKE_SIG, KEYRING_SERVICE, NEW_MESSAGE_SUB_ID,
|
ALL_MESSAGES_SUB_ID, APP_ID, BOOTSTRAP_RELAYS, DEVICE_ANNOUNCEMENT_KIND,
|
||||||
|
DEVICE_REQUEST_KIND, DEVICE_RESPONSE_KIND, DEVICE_SUB_ID, NEW_MESSAGE_SUB_ID,
|
||||||
},
|
},
|
||||||
profile::NostrProfile,
|
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::prelude::*;
|
nips::nip59::UnwrappedGift, pool::prelude::ReqExitPolicy, Event, Filter, Keys, Kind, PublicKey,
|
||||||
use state::{get_client, initialize_client};
|
RelayMessage, RelayPoolNotification, SubscribeAutoCloseOptions, SubscriptionId, TagKind,
|
||||||
use std::{borrow::Cow, collections::HashSet, str::FromStr, sync::Arc, time::Duration};
|
};
|
||||||
use tokio::sync::{mpsc, oneshot};
|
use smol::Timer;
|
||||||
|
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!(main_menu, [Quit]);
|
actions!(coop, [Quit]);
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Debug)]
|
||||||
pub 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() {
|
||||||
// Initialize Nostr client
|
// Enable logging
|
||||||
initialize_client();
|
tracing_subscriber::fmt::init();
|
||||||
|
|
||||||
// Get client
|
let (event_tx, event_rx) = smol::channel::bounded::<Signal>(1024);
|
||||||
|
let (batch_tx, batch_rx) = smol::channel::bounded::<Vec<PublicKey>>(100);
|
||||||
|
|
||||||
|
// Initialize nostr client
|
||||||
let client = get_client();
|
let client = get_client();
|
||||||
let (signal_tx, mut signal_rx) = tokio::sync::mpsc::channel::<Signal>(2048);
|
|
||||||
|
|
||||||
spawn(async move {
|
// Initialize application
|
||||||
// Add some bootstrap relays
|
let app = Application::new()
|
||||||
_ = client.add_relay("wss://relay.damus.io/").await;
|
.with_assets(Assets)
|
||||||
_ = client.add_relay("wss://relay.primal.net/").await;
|
.with_http_client(Arc::new(reqwest_client::ReqwestClient::new()));
|
||||||
_ = client.add_relay("wss://user.kindpag.es/").await;
|
|
||||||
_ = client.add_relay("wss://directory.yabu.me/").await;
|
// Connect to default relays
|
||||||
|
app.background_executor()
|
||||||
|
.spawn(async {
|
||||||
|
// Fix crash on startup
|
||||||
|
// TODO: why this is needed?
|
||||||
|
_ = rustls::crypto::aws_lc_rs::default_provider().install_default();
|
||||||
|
|
||||||
|
for relay in BOOTSTRAP_RELAYS.into_iter() {
|
||||||
|
_ = client.add_relay(relay).await;
|
||||||
|
}
|
||||||
|
|
||||||
_ = client.add_discovery_relay("wss://relaydiscovery.com").await;
|
_ = client.add_discovery_relay("wss://relaydiscovery.com").await;
|
||||||
|
_ = client.add_discovery_relay("wss://user.kindpag.es").await;
|
||||||
|
|
||||||
// Connect to all relays
|
|
||||||
_ = client.connect().await
|
_ = client.connect().await
|
||||||
});
|
})
|
||||||
|
.detach();
|
||||||
|
|
||||||
spawn(async move {
|
// Handle batch metadata
|
||||||
let (batch_tx, mut batch_rx) = mpsc::channel::<Cow<Event>>(20);
|
app.background_executor()
|
||||||
|
.spawn(async move {
|
||||||
async fn sync_metadata(client: &Client, buffer: &HashSet<PublicKey>) {
|
|
||||||
let filter = Filter::new()
|
|
||||||
.authors(buffer.iter().copied())
|
|
||||||
.kind(Kind::Metadata)
|
|
||||||
.limit(buffer.len());
|
|
||||||
|
|
||||||
if let Err(e) = client.sync(filter, &SyncOptions::default()).await {
|
|
||||||
error!("NEG error: {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn process_batch(client: &Client, events: &[Cow<'_, Event>]) {
|
|
||||||
let sig = Signature::from_str(FAKE_SIG).unwrap();
|
|
||||||
let mut buffer: HashSet<PublicKey> = HashSet::with_capacity(20);
|
|
||||||
|
|
||||||
for event in events.iter() {
|
|
||||||
if let Ok(UnwrappedGift { mut rumor, sender }) =
|
|
||||||
client.unwrap_gift_wrap(event).await
|
|
||||||
{
|
|
||||||
let pubkeys: HashSet<PublicKey> = event.tags.public_keys().copied().collect();
|
|
||||||
buffer.extend(pubkeys);
|
|
||||||
buffer.insert(sender);
|
|
||||||
|
|
||||||
// Create event's ID is not exist
|
|
||||||
rumor.ensure_id();
|
|
||||||
|
|
||||||
// Save event to database
|
|
||||||
if let Some(id) = rumor.id {
|
|
||||||
let ev = Event::new(
|
|
||||||
id,
|
|
||||||
rumor.pubkey,
|
|
||||||
rumor.created_at,
|
|
||||||
rumor.kind,
|
|
||||||
rumor.tags,
|
|
||||||
rumor.content,
|
|
||||||
sig,
|
|
||||||
);
|
|
||||||
|
|
||||||
if let Err(e) = client.database().save_event(&ev).await {
|
|
||||||
error!("Save error: {}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sync_metadata(client, &buffer).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Spawn a thread to handle batch process
|
|
||||||
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 = Vec::with_capacity(20);
|
let mut batch: HashSet<PublicKey> = HashSet::new();
|
||||||
let mut timeout = Box::pin(tokio::time::sleep(BATCH_TIMEOUT));
|
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
tokio::select! {
|
let mut timeout = Box::pin(Timer::after(BATCH_TIMEOUT).fuse());
|
||||||
event = batch_rx.recv() => {
|
|
||||||
if let Some(event) = event {
|
|
||||||
batch.push(event);
|
|
||||||
|
|
||||||
if batch.len() == BATCH_SIZE {
|
select! {
|
||||||
process_batch(client, &batch).await;
|
pubkeys = batch_rx.recv().fuse() => {
|
||||||
batch.clear();
|
match pubkeys {
|
||||||
timeout = Box::pin(tokio::time::sleep(BATCH_TIMEOUT));
|
Ok(keys) => {
|
||||||
}
|
batch.extend(keys);
|
||||||
} else {
|
if batch.len() >= BATCH_SIZE {
|
||||||
break;
|
handle_metadata(mem::take(&mut batch)).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ = &mut timeout => {
|
Err(_) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = timeout => {
|
||||||
if !batch.is_empty() {
|
if !batch.is_empty() {
|
||||||
process_batch(client, &batch).await;
|
handle_metadata(mem::take(&mut batch)).await;
|
||||||
batch.clear();
|
|
||||||
}
|
|
||||||
timeout = Box::pin(tokio::time::sleep(BATCH_TIMEOUT));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
|
||||||
|
// Handle notifications
|
||||||
|
app.background_executor()
|
||||||
|
.spawn(async move {
|
||||||
|
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 sig = Signature::from_str(FAKE_SIG).unwrap();
|
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 {
|
||||||
@@ -152,118 +130,102 @@ fn main() {
|
|||||||
RelayMessage::Event {
|
RelayMessage::Event {
|
||||||
event,
|
event,
|
||||||
subscription_id,
|
subscription_id,
|
||||||
} => match event.kind {
|
} => {
|
||||||
|
match event.kind {
|
||||||
Kind::GiftWrap => {
|
Kind::GiftWrap => {
|
||||||
|
if let Ok(gift) = handle_gift_wrap(&event).await {
|
||||||
|
// Sign the rumor with the generated keys,
|
||||||
|
// this event will be used for internal only,
|
||||||
|
// and NEVER send to relays.
|
||||||
|
if let Ok(event) = gift.rumor.sign_with_keys(&rng_keys) {
|
||||||
|
let mut pubkeys = vec![];
|
||||||
|
pubkeys.extend(event.tags.public_keys());
|
||||||
|
pubkeys.push(event.pubkey);
|
||||||
|
|
||||||
|
// Save the event to the database, use for query directly.
|
||||||
|
_ = client.database().save_event(&event).await;
|
||||||
|
|
||||||
|
// Send this event to the GPUI
|
||||||
if new_id == *subscription_id {
|
if new_id == *subscription_id {
|
||||||
if let Ok(UnwrappedGift { mut rumor, .. }) =
|
_ = event_tx.send(Signal::Event(event)).await;
|
||||||
client.unwrap_gift_wrap(&event).await
|
|
||||||
{
|
|
||||||
// Compute event id if not exist
|
|
||||||
rumor.ensure_id();
|
|
||||||
|
|
||||||
if let Some(id) = rumor.id {
|
|
||||||
let ev = Event::new(
|
|
||||||
id,
|
|
||||||
rumor.pubkey,
|
|
||||||
rumor.created_at,
|
|
||||||
rumor.kind,
|
|
||||||
rumor.tags,
|
|
||||||
rumor.content,
|
|
||||||
sig,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Save rumor to database to further query
|
|
||||||
if let Err(e) = client.database().save_event(&ev).await {
|
|
||||||
error!("Save error: {}", e);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send new event to GPUI
|
// Send all pubkeys to the batch
|
||||||
if let Err(e) = signal_tx.send(Signal::Event(ev)).await {
|
_ = batch_tx.send(pubkeys).await;
|
||||||
error!("Send error: {}", e)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if let Err(e) = batch_tx.send(event).await {
|
|
||||||
error!("Failed to add to batch: {}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Kind::ContactList => {
|
Kind::ContactList => {
|
||||||
let public_keys: HashSet<_> =
|
let pubkeys =
|
||||||
event.tags.public_keys().copied().collect();
|
event.tags.public_keys().copied().collect::<HashSet<_>>();
|
||||||
|
|
||||||
sync_metadata(client, &public_keys).await;
|
handle_metadata(pubkeys).await;
|
||||||
}
|
}
|
||||||
_ => {}
|
Kind::Custom(DEVICE_REQUEST_KIND) => {
|
||||||
},
|
log::info!("Received device keys request");
|
||||||
RelayMessage::EndOfStoredEvents(subscription_id) => {
|
|
||||||
if all_id == *subscription_id {
|
|
||||||
if let Err(e) = signal_tx.send(Signal::Eose).await {
|
|
||||||
error!("Failed to send eose: {}", e)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let app = Application::new()
|
_ = event_tx
|
||||||
.with_assets(Assets)
|
.send(Signal::RequestMasterKey(event.into_owned()))
|
||||||
.with_http_client(Arc::new(reqwest_client::ReqwestClient::new()));
|
.await;
|
||||||
|
}
|
||||||
|
Kind::Custom(DEVICE_RESPONSE_KIND) => {
|
||||||
|
log::info!("Received device keys approval");
|
||||||
|
|
||||||
app.on_reopen(move |cx| {
|
_ = event_tx
|
||||||
let client = get_client();
|
.send(Signal::ReceiveMasterKey(event.into_owned()))
|
||||||
let (tx, rx) = oneshot::channel::<Option<NostrProfile>>();
|
.await;
|
||||||
|
}
|
||||||
|
Kind::Custom(DEVICE_ANNOUNCEMENT_KIND) => {
|
||||||
|
log::info!("Device Announcement received");
|
||||||
|
|
||||||
cx.spawn(|mut cx| async move {
|
|
||||||
cx.background_spawn(async move {
|
|
||||||
if let Ok(signer) = client.signer().await {
|
if let Ok(signer) = client.signer().await {
|
||||||
if let Ok(public_key) = signer.get_public_key().await {
|
if let Ok(public_key) = signer.get_public_key().await {
|
||||||
let metadata = if let Ok(Some(metadata)) =
|
if event.pubkey == public_key {
|
||||||
client.database().metadata(public_key).await
|
if let Some(tag) = event
|
||||||
|
.tags
|
||||||
|
.find(TagKind::custom("client"))
|
||||||
|
.and_then(|tag| tag.content())
|
||||||
{
|
{
|
||||||
metadata
|
set_device_name(tag).await;
|
||||||
} else {
|
}
|
||||||
Metadata::new()
|
}
|
||||||
};
|
}
|
||||||
|
}
|
||||||
_ = tx.send(Some(NostrProfile::new(public_key, metadata)));
|
}
|
||||||
} else {
|
_ => {}
|
||||||
_ = tx.send(None);
|
}
|
||||||
|
}
|
||||||
|
RelayMessage::EndOfStoredEvents(subscription_id) => {
|
||||||
|
if all_id == *subscription_id {
|
||||||
|
_ = event_tx.send(Signal::Eose).await;
|
||||||
|
} else if device_id == *subscription_id {
|
||||||
|
_ = event_tx.send(Signal::ReceiveAnnouncement).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
_ = tx.send(None);
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.detach();
|
.detach();
|
||||||
|
|
||||||
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)],
|
||||||
}]);
|
}]);
|
||||||
|
|
||||||
|
// Set up the window options
|
||||||
let opts = WindowOptions {
|
let opts = WindowOptions {
|
||||||
#[cfg(not(target_os = "linux"))]
|
#[cfg(not(target_os = "linux"))]
|
||||||
titlebar: Some(TitlebarOptions {
|
titlebar: Some(TitlebarOptions {
|
||||||
@@ -281,151 +243,118 @@ 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()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Open a window with default options
|
||||||
cx.open_window(opts, |window, cx| {
|
cx.open_window(opts, |window, cx| {
|
||||||
window.set_window_title(APP_NAME);
|
// Automatically sync theme with system appearance
|
||||||
window.set_app_id(APP_ID);
|
|
||||||
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 =
|
cx.spawn_in(window, |_, mut cx| async move {
|
||||||
if let Ok(Some(metadata)) = client.database().metadata(public_key).await {
|
while let Ok(signal) = event_rx.recv().await {
|
||||||
metadata
|
cx.update(|window, cx| {
|
||||||
} 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();
|
|
||||||
|
|
||||||
// Listen for messages from the Nostr thread
|
|
||||||
cx.spawn(|cx| async move {
|
|
||||||
while let Some(signal) = signal_rx.recv().await {
|
|
||||||
match signal {
|
match signal {
|
||||||
Signal::Eose => {
|
Signal::Eose => {
|
||||||
_ = cx.update(|cx| {
|
|
||||||
if let Some(chats) = ChatRegistry::global(cx) {
|
if let Some(chats) = ChatRegistry::global(cx) {
|
||||||
chats.update(cx, |this, cx| this.load_chat_rooms(cx))
|
chats.update(cx, |this, cx| this.load_chat_rooms(cx))
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
}
|
||||||
Signal::Event(event) => {
|
Signal::Event(event) => {
|
||||||
_ = cx.update(|cx| {
|
|
||||||
if let Some(chats) = ChatRegistry::global(cx) {
|
if let Some(chats) = ChatRegistry::global(cx) {
|
||||||
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();
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.detach();
|
.detach();
|
||||||
|
|
||||||
root
|
root
|
||||||
})
|
})
|
||||||
.expect("System error. Please re-open the app.");
|
})
|
||||||
|
.expect("Failed to open window. Please restart the application.");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn restore_window(profile: Option<NostrProfile>, cx: &mut AsyncApp) -> Result<()> {
|
async fn handle_gift_wrap(gift_wrap: &Event) -> Result<UnwrappedGift, anyhow::Error> {
|
||||||
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 {
|
if let Some(device) = get_device_keys().await {
|
||||||
_ = cx.open_window(opts, |window, cx| {
|
// Try to unwrap with the device keys first
|
||||||
window.set_window_title(APP_NAME);
|
match UnwrappedGift::from_gift_wrap(&device, gift_wrap).await {
|
||||||
window.set_app_id(APP_ID);
|
Ok(event) => Ok(event),
|
||||||
window
|
Err(_) => {
|
||||||
.observe_window_appearance(|window, cx| {
|
// Try to unwrap again with the user's signer
|
||||||
Theme::sync_system_appearance(Some(window), cx);
|
let signer = client.signer().await?;
|
||||||
})
|
let event = UnwrappedGift::from_gift_wrap(&signer, gift_wrap).await?;
|
||||||
.detach();
|
Ok(event)
|
||||||
|
}
|
||||||
cx.new(|cx| Root::new(app::init(profile, window, cx).into(), window, cx))
|
}
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
_ = cx.open_window(opts, |window, cx| {
|
Err(anyhow!("Signer not found"))
|
||||||
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))
|
async fn handle_metadata(buffer: HashSet<PublicKey>) {
|
||||||
});
|
let client = get_client();
|
||||||
};
|
|
||||||
|
|
||||||
Ok(())
|
let opts = SubscribeAutoCloseOptions::default()
|
||||||
|
.exit_policy(ReqExitPolicy::ExitOnEOSE)
|
||||||
|
.idle_timeout(Some(Duration::from_secs(2)));
|
||||||
|
|
||||||
|
let filter = Filter::new()
|
||||||
|
.authors(buffer.iter().cloned())
|
||||||
|
.limit(buffer.len() * 2)
|
||||||
|
.kinds(vec![Kind::Metadata, Kind::Custom(DEVICE_ANNOUNCEMENT_KIND)]);
|
||||||
|
|
||||||
|
if let Err(e) = client.subscribe(filter, Some(opts)).await {
|
||||||
|
log::error!("Failed to sync metadata: {e}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,11 @@
|
|||||||
use cargo_packager_updater::{check_update, semver::Version, url::Url};
|
use global::get_client;
|
||||||
use common::{
|
|
||||||
constants::{UPDATER_PUBKEY, UPDATER_URL},
|
|
||||||
profile::NostrProfile,
|
|
||||||
};
|
|
||||||
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 log::info;
|
|
||||||
use nostr_sdk::prelude::*;
|
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use state::get_client;
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::oneshot;
|
|
||||||
use ui::{
|
use ui::{
|
||||||
button::{Button, ButtonRounded, ButtonVariants},
|
button::{Button, ButtonRounded, ButtonVariants},
|
||||||
dock_area::{dock::DockPlacement, DockArea, DockItem},
|
dock_area::{dock::DockPlacement, DockArea, DockItem},
|
||||||
@@ -22,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 {
|
||||||
@@ -44,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();
|
||||||
@@ -88,107 +82,81 @@ impl AppView {
|
|||||||
view.set_center(center_panel, window, cx);
|
view.set_center(center_panel, window, cx);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check and auto update to the latest version
|
cx.new(|_| Self { dock })
|
||||||
cx.background_spawn(async move {
|
}
|
||||||
// Set auto updater config
|
|
||||||
let config = cargo_packager_updater::Config {
|
|
||||||
endpoints: vec![Url::parse(UPDATER_URL).expect("Failed to parse UPDATER URL")],
|
|
||||||
pubkey: String::from(UPDATER_PUBKEY),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
// Run auto updater
|
fn render_mode_btn(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
if let Ok(current_version) = Version::parse(env!("CARGO_PKG_VERSION")) {
|
Button::new("appearance")
|
||||||
if let Ok(Some(update)) = check_update(current_version, config) {
|
.xsmall()
|
||||||
if update.download_and_install().is_ok() {
|
.ghost()
|
||||||
info!("Update installed")
|
.map(|this| {
|
||||||
}
|
if cx.theme().appearance.is_dark() {
|
||||||
}
|
this.icon(IconName::Sun)
|
||||||
|
} else {
|
||||||
|
this.icon(IconName::Moon)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.detach();
|
.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);
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
cx.new(|cx| {
|
fn render_account_btn(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
let public_key = account.public_key();
|
Button::new("account")
|
||||||
let relays = cx.new(|_| None);
|
.ghost()
|
||||||
let async_relays = relays.downgrade();
|
.xsmall()
|
||||||
|
.reverse()
|
||||||
// Check user's messaging relays and determine user is ready for NIP17 or not.
|
.icon(Icon::new(IconName::ChevronDownSmall))
|
||||||
// If not, show the setup modal and instruct user setup inbox relays
|
.when_some(Device::global(cx), |this, account| {
|
||||||
let client = get_client();
|
this.when_some(account.read(cx).profile(), |this, profile| {
|
||||||
let window_handle = window.window_handle();
|
this.child(
|
||||||
let (tx, rx) = oneshot::channel::<Option<Vec<String>>>();
|
img(profile.avatar.clone())
|
||||||
|
.size_5()
|
||||||
let this = Self {
|
.rounded_full()
|
||||||
account,
|
.object_fit(ObjectFit::Cover),
|
||||||
relays,
|
|
||||||
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<_>>(),
|
|
||||||
)
|
)
|
||||||
} 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 is not configured")
|
|
||||||
.child(relays.clone())
|
.child(relays.clone())
|
||||||
.footer(
|
.footer(
|
||||||
div()
|
div()
|
||||||
@@ -211,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);
|
||||||
@@ -345,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();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -362,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
|
||||||
@@ -377,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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,9 +17,7 @@ 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 tokio::sync::oneshot;
|
|
||||||
use ui::{
|
use ui::{
|
||||||
button::{Button, ButtonRounded, ButtonVariants},
|
button::{Button, ButtonRounded, ButtonVariants},
|
||||||
dock_area::panel::{Panel, PanelEvent},
|
dock_area::panel::{Panel, PanelEvent},
|
||||||
@@ -30,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(
|
||||||
@@ -40,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"))
|
||||||
}
|
}
|
||||||
@@ -50,22 +49,37 @@ pub fn init(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(PartialEq, Eq)]
|
#[derive(PartialEq, Eq)]
|
||||||
struct ChatItem {
|
struct ParsedMessage {
|
||||||
profile: NostrProfile,
|
avatar: SharedString,
|
||||||
|
display_name: SharedString,
|
||||||
|
created_at: SharedString,
|
||||||
content: SharedString,
|
content: SharedString,
|
||||||
ago: SharedString,
|
}
|
||||||
|
|
||||||
|
impl ParsedMessage {
|
||||||
|
pub fn new(profile: &NostrProfile, content: &str, created_at: Timestamp) -> Self {
|
||||||
|
let content = SharedString::new(content);
|
||||||
|
let created_at = LastSeen(created_at).human_readable();
|
||||||
|
|
||||||
|
Self {
|
||||||
|
avatar: profile.avatar.clone(),
|
||||||
|
display_name: profile.name.clone(),
|
||||||
|
created_at,
|
||||||
|
content,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(PartialEq, Eq)]
|
#[derive(PartialEq, Eq)]
|
||||||
enum Message {
|
enum Message {
|
||||||
Item(Box<ChatItem>),
|
User(Box<ParsedMessage>),
|
||||||
System(SharedString),
|
System(SharedString),
|
||||||
Placeholder,
|
Placeholder,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Message {
|
impl Message {
|
||||||
pub fn new(chat_message: ChatItem) -> Self {
|
pub fn new(message: ParsedMessage) -> Self {
|
||||||
Self::Item(Box::new(chat_message))
|
Self::User(Box::new(message))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn system(content: SharedString) -> Self {
|
pub fn system(content: SharedString) -> Self {
|
||||||
@@ -80,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>,
|
||||||
@@ -97,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)
|
||||||
@@ -112,17 +126,32 @@ 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);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)];
|
));
|
||||||
|
|
||||||
let list_state = ListState::new(0, ListAlignment::Bottom, px(1024.), {
|
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
|
||||||
|
// [item_count] always equal to 1 at the beginning
|
||||||
|
let list_state = ListState::new(1, ListAlignment::Bottom, px(1024.), {
|
||||||
let this = cx.entity().downgrade();
|
let this = cx.entity().downgrade();
|
||||||
move |ix, window, cx| {
|
move |ix, window, cx| {
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
@@ -132,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,
|
||||||
@@ -153,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
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -166,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();
|
||||||
@@ -221,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);
|
||||||
@@ -278,34 +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 ago = LastSeen(Timestamp::now()).human_readable();
|
|
||||||
let message = Message::new(ChatItem {
|
|
||||||
profile: room.owner.clone(),
|
|
||||||
content: content.into(),
|
|
||||||
ago,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,40 +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()
|
|
||||||
};
|
|
||||||
|
|
||||||
Some(Message::new(ChatItem {
|
|
||||||
profile: member,
|
|
||||||
content: ev.content.into(),
|
|
||||||
ago: LastSeen(ev.created_at).human_readable(),
|
|
||||||
}))
|
|
||||||
} 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| {
|
||||||
@@ -355,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(ChatItem {
|
|
||||||
profile,
|
|
||||||
content: event.content.clone().into(),
|
|
||||||
ago: LastSeen(event.created_at).human_readable(),
|
|
||||||
});
|
|
||||||
|
|
||||||
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>) {
|
||||||
@@ -448,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();
|
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.clone(), 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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -582,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,
|
||||||
@@ -597,7 +482,7 @@ impl Chat {
|
|||||||
.w_full()
|
.w_full()
|
||||||
.p_2()
|
.p_2()
|
||||||
.map(|this| match message {
|
.map(|this| match message {
|
||||||
Message::Item(item) => this
|
Message::User(item) => this
|
||||||
.hover(|this| this.bg(cx.theme().accent.step(cx, ColorScaleStep::ONE)))
|
.hover(|this| this.bg(cx.theme().accent.step(cx, ColorScaleStep::ONE)))
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
@@ -612,7 +497,7 @@ impl Chat {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
img(item.profile.avatar())
|
img(item.avatar.clone())
|
||||||
.size_8()
|
.size_8()
|
||||||
.rounded_full()
|
.rounded_full()
|
||||||
.flex_shrink_0(),
|
.flex_shrink_0(),
|
||||||
@@ -629,8 +514,10 @@ impl Chat {
|
|||||||
.items_baseline()
|
.items_baseline()
|
||||||
.gap_2()
|
.gap_2()
|
||||||
.text_xs()
|
.text_xs()
|
||||||
.child(div().font_semibold().child(item.profile.name()))
|
.child(
|
||||||
.child(div().child(item.ago.clone()).text_color(
|
div().font_semibold().child(item.display_name.clone()),
|
||||||
|
)
|
||||||
|
.child(div().child(item.created_at.clone()).text_color(
|
||||||
cx.theme().base.step(cx, ColorScaleStep::ELEVEN),
|
cx.theme().base.step(cx, ColorScaleStep::ELEVEN),
|
||||||
)),
|
)),
|
||||||
)
|
)
|
||||||
@@ -649,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(),
|
||||||
@@ -667,14 +554,14 @@ impl Chat {
|
|||||||
.text_center()
|
.text_center()
|
||||||
.text_xs()
|
.text_xs()
|
||||||
.text_color(cx.theme().base.step(cx, ColorScaleStep::ELEVEN))
|
.text_color(cx.theme().base.step(cx, ColorScaleStep::ELEVEN))
|
||||||
.line_height(relative(1.))
|
.line_height(relative(1.2))
|
||||||
.child(
|
.child(
|
||||||
svg()
|
svg()
|
||||||
.path("brand/coop.svg")
|
.path("brand/coop.svg")
|
||||||
.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()
|
||||||
@@ -689,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()
|
||||||
@@ -713,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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +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 tokio::sync::oneshot;
|
|
||||||
use ui::{
|
use ui::{
|
||||||
button::Button,
|
button::Button,
|
||||||
dock_area::panel::{Panel, PanelEvent},
|
dock_area::panel::{Panel, PanelEvent},
|
||||||
@@ -142,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))
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -1,140 +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 tokio::sync::oneshot;
|
|
||||||
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>) {
|
||||||
@@ -142,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);
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -369,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)),
|
||||||
)
|
)
|
||||||
@@ -381,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()
|
||||||
@@ -389,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);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
})),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +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 tokio::sync::oneshot;
|
|
||||||
use ui::{
|
use ui::{
|
||||||
button::{Button, ButtonVariants},
|
button::{Button, ButtonVariants},
|
||||||
dock_area::panel::{Panel, PanelEvent},
|
dock_area::panel::{Panel, PanelEvent},
|
||||||
@@ -18,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>,
|
||||||
@@ -33,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
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,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);
|
||||||
@@ -222,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)
|
||||||
}
|
}
|
||||||
@@ -269,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(),
|
||||||
|
|||||||
@@ -1,10 +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 tokio::sync::oneshot;
|
|
||||||
use ui::{
|
use ui::{
|
||||||
button::{Button, ButtonVariants},
|
button::{Button, ButtonVariants},
|
||||||
input::{InputEvent, TextInput},
|
input::{InputEvent, TextInput},
|
||||||
@@ -12,95 +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);
|
||||||
|
|
||||||
cx.spawn(|this, mut cx| async move {
|
let task: Task<Result<EventId, Error>> = cx.background_spawn(async move {
|
||||||
let (tx, rx) = oneshot::channel();
|
|
||||||
|
|
||||||
cx.background_spawn(async move {
|
|
||||||
let client = get_client();
|
let client = get_client();
|
||||||
let signer = client.signer().await.unwrap();
|
let signer = client.signer().await?;
|
||||||
let public_key = signer.get_public_key().await.unwrap();
|
let public_key = signer.get_public_key().await?;
|
||||||
|
|
||||||
|
// If user didn't have any NIP-65 relays, add default ones
|
||||||
|
if client.database().relay_list(public_key).await?.is_empty() {
|
||||||
|
let builder = EventBuilder::relay_list(vec![
|
||||||
|
(RelayUrl::parse("wss://relay.damus.io/").unwrap(), None),
|
||||||
|
(RelayUrl::parse("wss://relay.primal.net/").unwrap(), None),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if let Err(e) = client.send_event_builder(builder).await {
|
||||||
|
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 event = EventBuilder::new(Kind::InboxRelays, "")
|
let builder = EventBuilder::new(Kind::InboxRelays, "").tags(tags);
|
||||||
.tags(tags)
|
let output = client.send_event_builder(builder).await?;
|
||||||
.build(public_key)
|
|
||||||
.sign(&signer)
|
// Connect to messaging relays
|
||||||
|
for relay in relays.into_iter() {
|
||||||
|
_ = client.add_relay(&relay).await;
|
||||||
|
_ = client.connect_relay(&relay).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
.await
|
||||||
.unwrap();
|
{
|
||||||
|
log::error!("Failed to subscribe to new messages: {}", e);
|
||||||
|
}
|
||||||
|
|
||||||
if let Ok(output) = client.send_event(&event).await {
|
Ok(output.val)
|
||||||
_ = tx.send(output.val);
|
});
|
||||||
};
|
|
||||||
})
|
|
||||||
.detach();
|
|
||||||
|
|
||||||
if rx.await.is_ok() {
|
cx.spawn(|this, mut cx| async move {
|
||||||
|
if task.await.is_ok() {
|
||||||
cx.update_window(window_handle, |_, window, cx| {
|
cx.update_window(window_handle, |_, window, cx| {
|
||||||
window.close_modal(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();
|
.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);
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.unwrap();
|
}
|
||||||
|
|
||||||
|
window.close_modal(cx);
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.detach();
|
.detach();
|
||||||
@@ -122,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);
|
||||||
@@ -151,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()
|
||||||
@@ -161,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()
|
||||||
@@ -168,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(
|
||||||
@@ -235,6 +334,7 @@ impl Render for Relays {
|
|||||||
items
|
items
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
.w_full()
|
||||||
.min_h(px(120.)),
|
.min_h(px(120.)),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
@@ -245,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)
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,19 +1,17 @@
|
|||||||
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 tokio::sync::oneshot;
|
|
||||||
use ui::{
|
use ui::{
|
||||||
button::{Button, ButtonRounded},
|
button::{Button, ButtonRounded},
|
||||||
input::{InputEvent, TextInput},
|
input::{InputEvent, TextInput},
|
||||||
@@ -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,6 +64,8 @@ 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,
|
||||||
@@ -82,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()
|
||||||
@@ -91,7 +92,6 @@ impl Compose {
|
|||||||
|
|
||||||
_ = tx.send(members);
|
_ = tx.send(members);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.detach();
|
.detach();
|
||||||
|
|
||||||
@@ -145,40 +145,40 @@ impl Compose {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let tags = Tags::new(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) => {
|
||||||
@@ -186,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();
|
||||||
@@ -207,42 +207,69 @@ impl Compose {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn add(&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);
|
||||||
|
|
||||||
if let Ok(public_key) = PublicKey::parse(&content) {
|
let task: Task<Result<NostrProfile, anyhow::Error>> = if content.contains("@") {
|
||||||
if self
|
cx.background_spawn(async move {
|
||||||
.contacts
|
let profile = nip05::profile(&content, None).await?;
|
||||||
.read(cx)
|
let public_key = profile.public_key;
|
||||||
.iter()
|
|
||||||
.any(|c| c.public_key() == public_key)
|
let metadata = client
|
||||||
{
|
.fetch_metadata(public_key, Duration::from_secs(2))
|
||||||
|
.await?
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
Ok(NostrProfile::new(public_key, metadata))
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
let Ok(public_key) = PublicKey::parse(&content) else {
|
||||||
self.set_loading(false, cx);
|
self.set_loading(false, cx);
|
||||||
|
self.set_error(Some("Public Key is not valid".into()), cx);
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
cx.spawn(|this, mut cx| async move {
|
|
||||||
let (tx, rx) = oneshot::channel::<Metadata>();
|
|
||||||
|
|
||||||
cx.background_spawn(async move {
|
cx.background_spawn(async move {
|
||||||
let client = get_client();
|
let metadata = client
|
||||||
let metadata = (client
|
.fetch_metadata(public_key, Duration::from_secs(2))
|
||||||
.fetch_metadata(public_key, Duration::from_secs(3))
|
.await?
|
||||||
.await)
|
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
_ = tx.send(metadata);
|
Ok(NostrProfile::new(public_key, metadata))
|
||||||
})
|
})
|
||||||
.detach();
|
};
|
||||||
|
|
||||||
|
cx.spawn(|this, mut cx| async move {
|
||||||
|
match task.await {
|
||||||
|
Ok(profile) => {
|
||||||
|
let public_key = profile.public_key;
|
||||||
|
|
||||||
|
_ = cx
|
||||||
|
.background_spawn(async move {
|
||||||
|
let opts = SubscribeAutoCloseOptions::default()
|
||||||
|
.exit_policy(ReqExitPolicy::ExitOnEOSE);
|
||||||
|
|
||||||
|
// Create a device announcement filter
|
||||||
|
let device = Filter::new()
|
||||||
|
.kind(Kind::Custom(DEVICE_ANNOUNCEMENT_KIND))
|
||||||
|
.author(public_key)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
// Only subscribe to the latest device announcement
|
||||||
|
client.subscribe(device, Some(opts)).await
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
if let Ok(metadata) = rx.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| {
|
||||||
|
let public_key = profile.public_key;
|
||||||
|
|
||||||
this.contacts.update(cx, |this, cx| {
|
this.contacts.update(cx, |this, cx| {
|
||||||
this.insert(0, NostrProfile::new(public_key, metadata));
|
this.insert(0, profile);
|
||||||
cx.notify();
|
cx.notify();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -262,12 +289,17 @@ impl Compose {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
Err(e) => {
|
||||||
|
_ = cx.update_window(window_handle, |_, _, cx| {
|
||||||
|
_ = this.update(cx, |this, cx| {
|
||||||
|
this.set_loading(false, cx);
|
||||||
|
this.set_error(Some(e.to_string().into()), cx);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.detach();
|
.detach();
|
||||||
} else {
|
|
||||||
self.set_loading(false, cx);
|
|
||||||
self.set_error(Some("Public Key is not valid".into()), cx);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_error(&mut self, error: Option<SharedString>, cx: &mut Context<Self>) {
|
fn set_error(&mut self, error: Option<SharedString>, cx: &mut Context<Self>) {
|
||||||
@@ -329,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(
|
||||||
@@ -372,9 +404,9 @@ impl Render for Compose {
|
|||||||
.small()
|
.small()
|
||||||
.rounded(ButtonRounded::Size(px(9999.)))
|
.rounded(ButtonRounded::Size(px(9999.)))
|
||||||
.loading(self.is_loading)
|
.loading(self.is_loading)
|
||||||
.on_click(
|
.on_click(cx.listener(|this, _, window, cx| {
|
||||||
cx.listener(|this, _, window, cx| this.add(window, cx)),
|
this.add(window, cx);
|
||||||
),
|
})),
|
||||||
)
|
)
|
||||||
.child(self.user_input.clone()),
|
.child(self.user_input.clone()),
|
||||||
)
|
)
|
||||||
@@ -420,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()
|
||||||
@@ -439,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(
|
||||||
@@ -463,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,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "chats"
|
name = "chats"
|
||||||
version = "0.1.0"
|
version = "0.0.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
publish = false
|
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
|
||||||
smol.workspace = true
|
|
||||||
chrono.workspace = true
|
chrono.workspace = true
|
||||||
|
smallvec.workspace = true
|
||||||
|
smol.workspace = true
|
||||||
|
oneshot.workspace = true
|
||||||
|
log.workspace = true
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
use anyhow::anyhow;
|
|
||||||
use async_utility::tokio::sync::oneshot;
|
|
||||||
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);
|
||||||
@@ -31,42 +31,9 @@ impl ChatRegistry {
|
|||||||
pub fn register(cx: &mut App) -> Entity<Self> {
|
pub fn register(cx: &mut App) -> Entity<Self> {
|
||||||
Self::global(cx).unwrap_or_else(|| {
|
Self::global(cx).unwrap_or_else(|| {
|
||||||
let entity = cx.new(Self::new);
|
let entity = cx.new(Self::new);
|
||||||
|
|
||||||
// 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
|
||||||
})
|
})
|
||||||
@@ -74,7 +41,7 @@ impl ChatRegistry {
|
|||||||
|
|
||||||
fn new(_cx: &mut Context<Self>) -> Self {
|
fn new(_cx: &mut Context<Self>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
rooms: Vec::with_capacity(5),
|
rooms: vec![],
|
||||||
is_loading: true,
|
is_loading: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -85,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())
|
||||||
@@ -102,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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -137,7 +115,7 @@ impl ChatRegistry {
|
|||||||
.detach();
|
.detach();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn rooms(&self) -> &Vec<Entity<Room>> {
|
pub fn rooms(&self) -> &[Entity<Room>] {
|
||||||
&self.rooms
|
&self.rooms
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,50 +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(¤t.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
|
||||||
|
self.rooms
|
||||||
|
.sort_by_key(|room| Reverse(room.read(cx).last_seen()));
|
||||||
} else {
|
} else {
|
||||||
let room = cx.new(|cx| Room::parse(&event, cx));
|
let new_room = Room::new(&event, cx);
|
||||||
self.rooms.insert(0, room);
|
|
||||||
|
// Push the new room to the front of the list
|
||||||
|
self.rooms.insert(0, new_room);
|
||||||
|
}
|
||||||
|
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,134 +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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get all public keys from current room
|
Ok(report)
|
||||||
pub fn pubkeys(&self) -> Vec<PublicKey> {
|
})
|
||||||
let mut pubkeys: Vec<_> = self.members.iter().map(|m| m.public_key()).collect();
|
}
|
||||||
pubkeys.push(self.owner.public_key());
|
|
||||||
|
|
||||||
pubkeys
|
/// Load metadata for all members
|
||||||
|
pub fn load_messages(&self, cx: &App) -> Task<Result<Events, Error>> {
|
||||||
|
let client = get_client();
|
||||||
|
let pubkeys = self.public_keys();
|
||||||
|
let filter = Filter::new()
|
||||||
|
.kind(Kind::PrivateDirectMessage)
|
||||||
|
.authors(pubkeys.iter().copied())
|
||||||
|
.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)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "common"
|
name = "common"
|
||||||
version = "0.1.0"
|
version = "0.0.0"
|
||||||
edition = "2021"
|
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"
|
||||||
|
|||||||
@@ -1,54 +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)]
|
||||||
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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,61 +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 subscription = Filter::new()
|
|
||||||
.kind(Kind::ContactList)
|
|
||||||
.author(public_key)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
// Get contact list
|
|
||||||
_ = client.sync(subscription, &SyncOptions::default()).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)?;
|
||||||
@@ -67,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()
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "state"
|
name = "global"
|
||||||
version = "0.1.0"
|
version = "0.0.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
publish = false
|
publish = false
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
nostr-sdk.workspace = true
|
nostr-sdk.workspace = true
|
||||||
tokio.workspace = true
|
|
||||||
dirs.workspace = true
|
dirs.workspace = true
|
||||||
|
smol.workspace = true
|
||||||
|
|
||||||
|
whoami = "1.5.2"
|
||||||
@@ -1,12 +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 FAKE_SIG: &str = "f9e79d141c004977192d05a86f81ec7c585179c371f7350a5412d33575a2a356433f58e405c2296ed273e2fe0aafa25b641e39cc4e1f3f261ebf55bce0cbac83";
|
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
104
crates/global/src/lib.rs
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
use constants::{ALL_MESSAGES_SUB_ID, APP_ID};
|
||||||
|
use dirs::config_dir;
|
||||||
|
use nostr_sdk::prelude::*;
|
||||||
|
use smol::lock::Mutex;
|
||||||
|
|
||||||
|
use std::{
|
||||||
|
fs,
|
||||||
|
sync::{Arc, OnceLock},
|
||||||
|
time::Duration,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub mod constants;
|
||||||
|
|
||||||
|
/// Nostr Client
|
||||||
|
static CLIENT: OnceLock<Client> = OnceLock::new();
|
||||||
|
/// Current App Name
|
||||||
|
static APP_NAME: OnceLock<Arc<str>> = OnceLock::new();
|
||||||
|
/// NIP-4e: Device Keys, used for encryption
|
||||||
|
static DEVICE_KEYS: Mutex<Option<Arc<dyn NostrSigner>>> = Mutex::new(None);
|
||||||
|
/// NIP-4e: Device Name, used for display purposes
|
||||||
|
static DEVICE_NAME: Mutex<Option<Arc<String>>> = Mutex::new(None);
|
||||||
|
|
||||||
|
/// Nostr Client instance
|
||||||
|
pub fn get_client() -> &'static Client {
|
||||||
|
CLIENT.get_or_init(|| {
|
||||||
|
// Setup app data folder
|
||||||
|
let config_dir = config_dir().expect("Config directory not found");
|
||||||
|
let app_dir = config_dir.join(APP_ID);
|
||||||
|
|
||||||
|
// Create app directory if it doesn't exist
|
||||||
|
_ = fs::create_dir_all(&app_dir);
|
||||||
|
|
||||||
|
// Setup database
|
||||||
|
let lmdb = NostrLMDB::open(app_dir.join("nostr")).expect("Database is NOT initialized");
|
||||||
|
|
||||||
|
// Client options
|
||||||
|
let opts = Options::new()
|
||||||
|
// NIP-65
|
||||||
|
.gossip(true)
|
||||||
|
// Skip all very slow relays
|
||||||
|
.max_avg_latency(Duration::from_secs(2));
|
||||||
|
|
||||||
|
// Setup Nostr Client
|
||||||
|
ClientBuilder::default().database(lmdb).opts(opts).build()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get app name
|
||||||
|
pub fn get_app_name() -> &'static str {
|
||||||
|
APP_NAME.get_or_init(|| {
|
||||||
|
Arc::from(format!(
|
||||||
|
"Coop on {} ({})",
|
||||||
|
whoami::distro(),
|
||||||
|
whoami::devicename()
|
||||||
|
))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get device keys
|
||||||
|
pub async fn get_device_keys() -> Option<Arc<dyn NostrSigner>> {
|
||||||
|
let guard = DEVICE_KEYS.lock().await;
|
||||||
|
guard.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set device keys
|
||||||
|
pub async fn set_device_keys<T>(signer: 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()))
|
||||||
|
}
|
||||||
@@ -1,28 +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() {
|
|
||||||
// Setup app data folder
|
|
||||||
let config_dir = config_dir().expect("Config directory not found");
|
|
||||||
let _ = fs::create_dir_all(config_dir.join("Coop/"));
|
|
||||||
|
|
||||||
// Setup database
|
|
||||||
let lmdb = NostrLMDB::open(config_dir.join("Coop/nostr")).expect("Database is NOT initialized");
|
|
||||||
|
|
||||||
// Client options
|
|
||||||
let opts = Options::new()
|
|
||||||
.gossip(true)
|
|
||||||
.max_avg_latency(Duration::from_secs(2));
|
|
||||||
|
|
||||||
// Setup Nostr Client
|
|
||||||
let client = ClientBuilder::default().database(lmdb).opts(opts).build();
|
|
||||||
|
|
||||||
CLIENT.set(client).expect("Client is already initialized!");
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_client() -> &'static Client {
|
|
||||||
CLIENT.get().expect("Client is NOT initialized!")
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "ui"
|
name = "ui"
|
||||||
version = "0.1.0"
|
version = "0.0.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
publish = false
|
publish = false
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use gpui::{
|
use gpui::{
|
||||||
div, prelude::FluentBuilder as _, px, AnyView, App, AppContext, Axis, Context, Element, Entity,
|
div, prelude::FluentBuilder as _, px, App, AppContext, Axis, Context, Element, Entity,
|
||||||
InteractiveElement as _, IntoElement, MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels,
|
InteractiveElement as _, IntoElement, MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels,
|
||||||
Point, Render, StatefulInteractiveElement, Style, Styled as _, WeakEntity, Window,
|
Point, Render, StatefulInteractiveElement, Style, Styled as _, WeakEntity, Window,
|
||||||
};
|
};
|
||||||
@@ -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()
|
||||||
@@ -374,10 +372,8 @@ 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, .. } => {
|
DockItem::Tabs { view, .. } => this.child(view.clone()),
|
||||||
this.child(AnyView::from(view.clone()).cached(cache_style))
|
DockItem::Panel { view, .. } => this.child(view.clone().view()),
|
||||||
}
|
|
||||||
DockItem::Panel { view, .. } => this.child(view.clone().view().cached(cache_style)),
|
|
||||||
})
|
})
|
||||||
.child(self.render_resize_handle(window, cx))
|
.child(self.render_resize_handle(window, cx))
|
||||||
.child(DockElement {
|
.child(DockElement {
|
||||||
@@ -432,14 +428,20 @@ impl Element for DockElement {
|
|||||||
_: &mut Self::RequestLayoutState,
|
_: &mut Self::RequestLayoutState,
|
||||||
_: &mut Self::PrepaintState,
|
_: &mut Self::PrepaintState,
|
||||||
window: &mut gpui::Window,
|
window: &mut gpui::Window,
|
||||||
_: &mut App,
|
cx: &mut App,
|
||||||
) {
|
) {
|
||||||
window.on_mouse_event({
|
window.on_mouse_event({
|
||||||
let view = self.view.clone();
|
let view = self.view.clone();
|
||||||
|
let is_resizing = view.read(cx).is_resizing;
|
||||||
move |e: &MouseMoveEvent, phase, window, cx| {
|
move |e: &MouseMoveEvent, phase, window, cx| {
|
||||||
if phase.bubble() {
|
if !is_resizing {
|
||||||
view.update(cx, |view, cx| view.resize(e.position, window, cx))
|
return;
|
||||||
}
|
}
|
||||||
|
if !phase.bubble() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
view.update(cx, |view, cx| view.resize(e.position, window, cx))
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -244,7 +243,7 @@ impl RenderOnce for Modal {
|
|||||||
.with_easing(cubic_bezier(0.32, 0.72, 0., 1.)),
|
.with_easing(cubic_bezier(0.32, 0.72, 0., 1.)),
|
||||||
move |this, delta| {
|
move |this, delta| {
|
||||||
let y_offset = px(0.) + delta * px(30.);
|
let y_offset = px(0.) + delta * px(30.);
|
||||||
this.top(y + y_offset).opacity(delta)
|
this.top(y + y_offset)
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -133,7 +133,6 @@ impl PopupMenu {
|
|||||||
this.dismiss(&Dismiss, window, cx)
|
this.dismiss(&Dismiss, window, cx)
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
let menu = Self {
|
let menu = Self {
|
||||||
focus_handle,
|
focus_handle,
|
||||||
action_focus_handle: None,
|
action_focus_handle: None,
|
||||||
@@ -150,7 +149,7 @@ impl PopupMenu {
|
|||||||
scroll_state: Rc::new(Cell::new(ScrollbarState::default())),
|
scroll_state: Rc::new(Cell::new(ScrollbarState::default())),
|
||||||
subscriptions,
|
subscriptions,
|
||||||
};
|
};
|
||||||
window.refresh();
|
|
||||||
f(menu, window, cx)
|
f(menu, window, cx)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -510,8 +510,11 @@ impl Element for ResizePanelGroupElement {
|
|||||||
let axis = self.axis;
|
let axis = self.axis;
|
||||||
let current_ix = view.read(cx).resizing_panel_ix;
|
let current_ix = view.read(cx).resizing_panel_ix;
|
||||||
move |e: &MouseMoveEvent, phase, window, cx| {
|
move |e: &MouseMoveEvent, phase, window, cx| {
|
||||||
if phase.bubble() {
|
if !phase.bubble() {
|
||||||
if let Some(ix) = current_ix {
|
return;
|
||||||
|
}
|
||||||
|
let Some(ix) = current_ix else { return };
|
||||||
|
|
||||||
view.update(cx, |view, cx| {
|
view.update(cx, |view, cx| {
|
||||||
let panel = view
|
let panel = view
|
||||||
.panels
|
.panels
|
||||||
@@ -520,31 +523,25 @@ impl Element for ResizePanelGroupElement {
|
|||||||
.read(cx);
|
.read(cx);
|
||||||
|
|
||||||
match axis {
|
match axis {
|
||||||
Axis::Horizontal => view.resize_panels(
|
Axis::Horizontal => {
|
||||||
ix,
|
view.resize_panels(ix, e.position.x - panel.bounds.left(), window, cx)
|
||||||
e.position.x - panel.bounds.left(),
|
}
|
||||||
window,
|
|
||||||
cx,
|
|
||||||
),
|
|
||||||
Axis::Vertical => {
|
Axis::Vertical => {
|
||||||
view.resize_panels(
|
view.resize_panels(ix, e.position.y - panel.bounds.top(), window, cx);
|
||||||
ix,
|
|
||||||
e.position.y - panel.bounds.top(),
|
|
||||||
window,
|
|
||||||
cx,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// When any mouse up, stop dragging
|
// When any mouse up, stop dragging
|
||||||
window.on_mouse_event({
|
window.on_mouse_event({
|
||||||
let view = self.view.clone();
|
let view = self.view.clone();
|
||||||
|
let current_ix = view.read(cx).resizing_panel_ix;
|
||||||
move |_: &MouseUpEvent, phase, window, cx| {
|
move |_: &MouseUpEvent, phase, window, cx| {
|
||||||
|
if current_ix.is_none() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if phase.bubble() {
|
if phase.bubble() {
|
||||||
view.update(cx, |view, cx| view.done_resizing(window, cx));
|
view.update(cx, |view, cx| view.done_resizing(window, cx));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
use gpui::*;
|
use gpui::{
|
||||||
|
fill, point, px, relative, App, Bounds, ContentMask, CursorStyle, Edges, Element, EntityId,
|
||||||
|
Hitbox, Hsla, IntoElement, MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad, Pixels,
|
||||||
|
Point, Position, ScrollHandle, ScrollWheelEvent, UniformListScrollHandle, Window,
|
||||||
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::{cell::Cell, rc::Rc, time::Instant};
|
use std::{
|
||||||
|
cell::Cell,
|
||||||
|
rc::Rc,
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
|
|
||||||
use crate::theme::{scale::ColorScaleStep, ActiveTheme};
|
use crate::theme::{scale::ColorScaleStep, ActiveTheme};
|
||||||
|
|
||||||
@@ -10,18 +18,24 @@ pub enum ScrollbarShow {
|
|||||||
#[default]
|
#[default]
|
||||||
Scrolling,
|
Scrolling,
|
||||||
Hover,
|
Hover,
|
||||||
|
Always,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ScrollbarShow {
|
impl ScrollbarShow {
|
||||||
fn is_hover(&self) -> bool {
|
fn is_hover(&self) -> bool {
|
||||||
matches!(self, Self::Hover)
|
matches!(self, Self::Hover)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_always(&self) -> bool {
|
||||||
|
matches!(self, Self::Always)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const BORDER_WIDTH: Pixels = px(0.);
|
const BORDER_WIDTH: Pixels = px(0.);
|
||||||
|
pub(crate) const WIDTH: Pixels = px(12.);
|
||||||
const MIN_THUMB_SIZE: f32 = 80.;
|
const MIN_THUMB_SIZE: f32 = 80.;
|
||||||
const THUMB_RADIUS: Pixels = Pixels(3.0);
|
const THUMB_RADIUS: Pixels = Pixels(4.0);
|
||||||
const THUMB_INSET: Pixels = Pixels(4.);
|
const THUMB_INSET: Pixels = Pixels(3.);
|
||||||
const FADE_OUT_DURATION: f32 = 3.0;
|
const FADE_OUT_DURATION: f32 = 3.0;
|
||||||
const FADE_OUT_DELAY: f32 = 2.0;
|
const FADE_OUT_DELAY: f32 = 2.0;
|
||||||
|
|
||||||
@@ -65,6 +79,8 @@ pub struct ScrollbarState {
|
|||||||
drag_pos: Point<Pixels>,
|
drag_pos: Point<Pixels>,
|
||||||
last_scroll_offset: Point<Pixels>,
|
last_scroll_offset: Point<Pixels>,
|
||||||
last_scroll_time: Option<Instant>,
|
last_scroll_time: Option<Instant>,
|
||||||
|
// Last update offset
|
||||||
|
last_update: Instant,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ScrollbarState {
|
impl Default for ScrollbarState {
|
||||||
@@ -76,6 +92,7 @@ impl Default for ScrollbarState {
|
|||||||
drag_pos: point(px(0.), px(0.)),
|
drag_pos: point(px(0.), px(0.)),
|
||||||
last_scroll_offset: point(px(0.), px(0.)),
|
last_scroll_offset: point(px(0.), px(0.)),
|
||||||
last_scroll_time: None,
|
last_scroll_time: None,
|
||||||
|
last_update: Instant::now(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -106,8 +123,8 @@ impl ScrollbarState {
|
|||||||
fn with_hovered(&self, axis: Option<ScrollbarAxis>) -> Self {
|
fn with_hovered(&self, axis: Option<ScrollbarAxis>) -> Self {
|
||||||
let mut state = *self;
|
let mut state = *self;
|
||||||
state.hovered_axis = axis;
|
state.hovered_axis = axis;
|
||||||
if self.is_scrollbar_visible() {
|
if axis.is_some() {
|
||||||
state.last_scroll_time = Some(Instant::now());
|
state.last_scroll_time = Some(std::time::Instant::now());
|
||||||
}
|
}
|
||||||
state
|
state
|
||||||
}
|
}
|
||||||
@@ -115,6 +132,9 @@ impl ScrollbarState {
|
|||||||
fn with_hovered_on_thumb(&self, axis: Option<ScrollbarAxis>) -> Self {
|
fn with_hovered_on_thumb(&self, axis: Option<ScrollbarAxis>) -> Self {
|
||||||
let mut state = *self;
|
let mut state = *self;
|
||||||
state.hovered_on_thumb = axis;
|
state.hovered_on_thumb = axis;
|
||||||
|
if axis.is_some() {
|
||||||
|
state.last_scroll_time = Some(std::time::Instant::now());
|
||||||
|
}
|
||||||
state
|
state
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,7 +155,18 @@ impl ScrollbarState {
|
|||||||
state
|
state
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn with_last_update(&self, t: Instant) -> Self {
|
||||||
|
let mut state = *self;
|
||||||
|
state.last_update = t;
|
||||||
|
state
|
||||||
|
}
|
||||||
|
|
||||||
fn is_scrollbar_visible(&self) -> bool {
|
fn is_scrollbar_visible(&self) -> bool {
|
||||||
|
// On drag
|
||||||
|
if self.dragged_axis.is_some() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(last_time) = self.last_scroll_time {
|
if let Some(last_time) = self.last_scroll_time {
|
||||||
let elapsed = Instant::now().duration_since(last_time).as_secs_f32();
|
let elapsed = Instant::now().duration_since(last_time).as_secs_f32();
|
||||||
elapsed < FADE_OUT_DURATION
|
elapsed < FADE_OUT_DURATION
|
||||||
@@ -178,9 +209,9 @@ impl ScrollbarAxis {
|
|||||||
match self {
|
match self {
|
||||||
Self::Vertical => vec![Self::Vertical],
|
Self::Vertical => vec![Self::Vertical],
|
||||||
Self::Horizontal => vec![Self::Horizontal],
|
Self::Horizontal => vec![Self::Horizontal],
|
||||||
// This should keep vertical first, vertical is the primary axis
|
// This should keep Horizontal first, Vertical is the primary axis
|
||||||
// if vertical not need display, then horizontal will not keep right margin.
|
// if Vertical not need display, then Horizontal will not keep right margin.
|
||||||
Self::Both => vec![Self::Vertical, Self::Horizontal],
|
Self::Both => vec![Self::Horizontal, Self::Vertical],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -189,11 +220,14 @@ impl ScrollbarAxis {
|
|||||||
pub struct Scrollbar {
|
pub struct Scrollbar {
|
||||||
view_id: EntityId,
|
view_id: EntityId,
|
||||||
axis: ScrollbarAxis,
|
axis: ScrollbarAxis,
|
||||||
/// When is vertical, this is the height of the scrollbar.
|
|
||||||
width: Pixels,
|
|
||||||
scroll_handle: Rc<Box<dyn ScrollHandleOffsetable>>,
|
scroll_handle: Rc<Box<dyn ScrollHandleOffsetable>>,
|
||||||
scroll_size: gpui::Size<Pixels>,
|
scroll_size: gpui::Size<Pixels>,
|
||||||
state: Rc<Cell<ScrollbarState>>,
|
state: Rc<Cell<ScrollbarState>>,
|
||||||
|
/// Maximum frames per second for scrolling by drag. Default is 120 FPS.
|
||||||
|
///
|
||||||
|
/// This is used to limit the update rate of the scrollbar when it is
|
||||||
|
/// being dragged for some complex interactions for reducing CPU usage.
|
||||||
|
max_fps: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Scrollbar {
|
impl Scrollbar {
|
||||||
@@ -209,8 +243,8 @@ impl Scrollbar {
|
|||||||
state,
|
state,
|
||||||
axis,
|
axis,
|
||||||
scroll_size,
|
scroll_size,
|
||||||
width: px(12.),
|
|
||||||
scroll_handle: Rc::new(Box::new(scroll_handle)),
|
scroll_handle: Rc::new(Box::new(scroll_handle)),
|
||||||
|
max_fps: 120,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,11 +324,21 @@ impl Scrollbar {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set maximum frames per second for scrolling by drag. Default is 120 FPS.
|
||||||
|
///
|
||||||
|
/// If you have very high CPU usage, consider reducing this value to improve performance.
|
||||||
|
///
|
||||||
|
/// Available values: 30..120
|
||||||
|
pub fn max_fps(mut self, max_fps: usize) -> Self {
|
||||||
|
self.max_fps = max_fps.clamp(30, 120);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
fn style_for_active(cx: &App) -> (Hsla, Hsla, Hsla, Pixels, Pixels) {
|
fn style_for_active(cx: &App) -> (Hsla, Hsla, Hsla, Pixels, Pixels) {
|
||||||
(
|
(
|
||||||
cx.theme().scrollbar_thumb_hover,
|
cx.theme().scrollbar_thumb_hover,
|
||||||
cx.theme().scrollbar,
|
cx.theme().scrollbar,
|
||||||
cx.theme().base.step(cx, ColorScaleStep::THREE),
|
cx.theme().base.step(cx, ColorScaleStep::SEVEN),
|
||||||
THUMB_INSET - px(1.),
|
THUMB_INSET - px(1.),
|
||||||
THUMB_RADIUS,
|
THUMB_RADIUS,
|
||||||
)
|
)
|
||||||
@@ -304,7 +348,7 @@ impl Scrollbar {
|
|||||||
(
|
(
|
||||||
cx.theme().scrollbar_thumb_hover,
|
cx.theme().scrollbar_thumb_hover,
|
||||||
cx.theme().scrollbar,
|
cx.theme().scrollbar,
|
||||||
cx.theme().base.step(cx, ColorScaleStep::THREE),
|
cx.theme().base.step(cx, ColorScaleStep::SIX),
|
||||||
THUMB_INSET - px(1.),
|
THUMB_INSET - px(1.),
|
||||||
THUMB_RADIUS,
|
THUMB_RADIUS,
|
||||||
)
|
)
|
||||||
@@ -382,11 +426,11 @@ impl Element for Scrollbar {
|
|||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
cx: &mut App,
|
cx: &mut App,
|
||||||
) -> (gpui::LayoutId, Self::RequestLayoutState) {
|
) -> (gpui::LayoutId, Self::RequestLayoutState) {
|
||||||
let style = Style {
|
let style = gpui::Style {
|
||||||
position: Position::Absolute,
|
position: Position::Absolute,
|
||||||
flex_grow: 1.0,
|
flex_grow: 1.0,
|
||||||
flex_shrink: 1.0,
|
flex_shrink: 1.0,
|
||||||
size: Size {
|
size: gpui::Size {
|
||||||
width: relative(1.).into(),
|
width: relative(1.).into(),
|
||||||
height: relative(1.).into(),
|
height: relative(1.).into(),
|
||||||
},
|
},
|
||||||
@@ -409,7 +453,6 @@ impl Element for Scrollbar {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let mut states = vec![];
|
let mut states = vec![];
|
||||||
|
|
||||||
let mut has_both = self.axis.is_both();
|
let mut has_both = self.axis.is_both();
|
||||||
|
|
||||||
for axis in self.axis.all().into_iter() {
|
for axis in self.axis.all().into_iter() {
|
||||||
@@ -430,7 +473,7 @@ impl Element for Scrollbar {
|
|||||||
|
|
||||||
// The horizontal scrollbar is set avoid overlapping with the vertical scrollbar, if the vertical scrollbar is visible.
|
// The horizontal scrollbar is set avoid overlapping with the vertical scrollbar, if the vertical scrollbar is visible.
|
||||||
let margin_end = if has_both && !is_vertical {
|
let margin_end = if has_both && !is_vertical {
|
||||||
self.width
|
WIDTH
|
||||||
} else {
|
} else {
|
||||||
px(0.)
|
px(0.)
|
||||||
};
|
};
|
||||||
@@ -449,31 +492,29 @@ impl Element for Scrollbar {
|
|||||||
|
|
||||||
let bounds = Bounds {
|
let bounds = Bounds {
|
||||||
origin: if is_vertical {
|
origin: if is_vertical {
|
||||||
point(
|
point(hitbox.origin.x + hitbox.size.width - WIDTH, hitbox.origin.y)
|
||||||
hitbox.origin.x + hitbox.size.width - self.width,
|
|
||||||
hitbox.origin.y,
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
point(
|
point(
|
||||||
hitbox.origin.x,
|
hitbox.origin.x,
|
||||||
hitbox.origin.y + hitbox.size.height - self.width,
|
hitbox.origin.y + hitbox.size.height - WIDTH,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
size: gpui::Size {
|
size: gpui::Size {
|
||||||
width: if is_vertical {
|
width: if is_vertical {
|
||||||
self.width
|
WIDTH
|
||||||
} else {
|
} else {
|
||||||
hitbox.size.width
|
hitbox.size.width
|
||||||
},
|
},
|
||||||
height: if is_vertical {
|
height: if is_vertical {
|
||||||
hitbox.size.height
|
hitbox.size.height
|
||||||
} else {
|
} else {
|
||||||
self.width
|
WIDTH
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
let state = self.state.clone();
|
let state = self.state.clone();
|
||||||
|
let is_always_to_show = cx.theme().scrollbar_show.is_always();
|
||||||
let is_hover_to_show = cx.theme().scrollbar_show.is_hover();
|
let is_hover_to_show = cx.theme().scrollbar_show.is_hover();
|
||||||
let is_hovered_on_bar = state.get().hovered_axis == Some(axis);
|
let is_hovered_on_bar = state.get().hovered_axis == Some(axis);
|
||||||
let is_hovered_on_thumb = state.get().hovered_on_thumb == Some(axis);
|
let is_hovered_on_thumb = state.get().hovered_on_thumb == Some(axis);
|
||||||
@@ -481,7 +522,9 @@ impl Element for Scrollbar {
|
|||||||
let (thumb_bg, bar_bg, bar_border, inset, radius) =
|
let (thumb_bg, bar_bg, bar_border, inset, radius) =
|
||||||
if state.get().dragged_axis == Some(axis) {
|
if state.get().dragged_axis == Some(axis) {
|
||||||
Self::style_for_active(cx)
|
Self::style_for_active(cx)
|
||||||
} else if is_hover_to_show && is_hovered_on_bar {
|
} else if (is_hover_to_show || is_always_to_show)
|
||||||
|
&& (is_hovered_on_bar || is_hovered_on_thumb)
|
||||||
|
{
|
||||||
if is_hovered_on_thumb {
|
if is_hovered_on_thumb {
|
||||||
Self::style_for_hovered_thumb(cx)
|
Self::style_for_hovered_thumb(cx)
|
||||||
} else {
|
} else {
|
||||||
@@ -520,12 +563,12 @@ impl Element for Scrollbar {
|
|||||||
let thumb_bounds = if is_vertical {
|
let thumb_bounds = if is_vertical {
|
||||||
Bounds::from_corners(
|
Bounds::from_corners(
|
||||||
point(bounds.origin.x, bounds.origin.y + thumb_start),
|
point(bounds.origin.x, bounds.origin.y + thumb_start),
|
||||||
point(bounds.origin.x + self.width, bounds.origin.y + thumb_end),
|
point(bounds.origin.x + WIDTH, bounds.origin.y + thumb_end),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Bounds::from_corners(
|
Bounds::from_corners(
|
||||||
point(bounds.origin.x + thumb_start, bounds.origin.y),
|
point(bounds.origin.x + thumb_start, bounds.origin.y),
|
||||||
point(bounds.origin.x + thumb_end, bounds.origin.y + self.width),
|
point(bounds.origin.x + thumb_end, bounds.origin.y + WIDTH),
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
let thumb_fill_bounds = if is_vertical {
|
let thumb_fill_bounds = if is_vertical {
|
||||||
@@ -535,7 +578,7 @@ impl Element for Scrollbar {
|
|||||||
bounds.origin.y + thumb_start + inset,
|
bounds.origin.y + thumb_start + inset,
|
||||||
),
|
),
|
||||||
point(
|
point(
|
||||||
bounds.origin.x + self.width - inset,
|
bounds.origin.x + WIDTH - inset,
|
||||||
bounds.origin.y + thumb_end - inset,
|
bounds.origin.y + thumb_end - inset,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -547,7 +590,7 @@ impl Element for Scrollbar {
|
|||||||
),
|
),
|
||||||
point(
|
point(
|
||||||
bounds.origin.x + thumb_end - inset,
|
bounds.origin.x + thumb_end - inset,
|
||||||
bounds.origin.y + self.width - inset,
|
bounds.origin.y + WIDTH - inset,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
@@ -589,6 +632,15 @@ impl Element for Scrollbar {
|
|||||||
let is_visible = self.state.get().is_scrollbar_visible();
|
let is_visible = self.state.get().is_scrollbar_visible();
|
||||||
let is_hover_to_show = cx.theme().scrollbar_show.is_hover();
|
let is_hover_to_show = cx.theme().scrollbar_show.is_hover();
|
||||||
|
|
||||||
|
// Update last_scroll_time when offset is changed.
|
||||||
|
if self.scroll_handle.offset() != self.state.get().last_scroll_offset {
|
||||||
|
self.state.set(
|
||||||
|
self.state
|
||||||
|
.get()
|
||||||
|
.with_last_scroll(self.scroll_handle.offset(), Some(Instant::now())),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
window.with_content_mask(
|
window.with_content_mask(
|
||||||
Some(ContentMask {
|
Some(ContentMask {
|
||||||
bounds: hitbox_bounds,
|
bounds: hitbox_bounds,
|
||||||
@@ -711,30 +763,36 @@ impl Element for Scrollbar {
|
|||||||
let scroll_handle = self.scroll_handle.clone();
|
let scroll_handle = self.scroll_handle.clone();
|
||||||
let state = self.state.clone();
|
let state = self.state.clone();
|
||||||
let view_id = self.view_id;
|
let view_id = self.view_id;
|
||||||
|
let max_fps_duration = Duration::from_millis((1000 / self.max_fps) as u64);
|
||||||
|
|
||||||
move |event: &MouseMoveEvent, _, _, cx| {
|
move |event: &MouseMoveEvent, _, _, cx| {
|
||||||
|
let mut notify = false;
|
||||||
|
// When is hover to show mode or it was visible,
|
||||||
|
// we need to update the hovered state and increase the last_scroll_time.
|
||||||
|
let need_hover_to_update = is_hover_to_show || is_visible;
|
||||||
// Update hovered state for scrollbar
|
// Update hovered state for scrollbar
|
||||||
if bounds.contains(&event.position) {
|
if bounds.contains(&event.position) && need_hover_to_update {
|
||||||
if state.get().hovered_axis != Some(axis) {
|
|
||||||
state.set(state.get().with_hovered(Some(axis)));
|
state.set(state.get().with_hovered(Some(axis)));
|
||||||
cx.notify(view_id);
|
|
||||||
|
if state.get().hovered_axis != Some(axis) {
|
||||||
|
notify = true;
|
||||||
}
|
}
|
||||||
} else if state.get().hovered_axis == Some(axis)
|
} else if state.get().hovered_axis == Some(axis)
|
||||||
&& state.get().hovered_axis.is_some()
|
&& state.get().hovered_axis.is_some()
|
||||||
{
|
{
|
||||||
state.set(state.get().with_hovered(None));
|
state.set(state.get().with_hovered(None));
|
||||||
cx.notify(view_id);
|
notify = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update hovered state for scrollbar thumb
|
// Update hovered state for scrollbar thumb
|
||||||
if thumb_bounds.contains(&event.position) {
|
if thumb_bounds.contains(&event.position) {
|
||||||
if state.get().hovered_on_thumb != Some(axis) {
|
if state.get().hovered_on_thumb != Some(axis) {
|
||||||
state.set(state.get().with_hovered_on_thumb(Some(axis)));
|
state.set(state.get().with_hovered_on_thumb(Some(axis)));
|
||||||
cx.notify(view_id);
|
notify = true;
|
||||||
}
|
}
|
||||||
} else if state.get().hovered_on_thumb == Some(axis) {
|
} else if state.get().hovered_on_thumb == Some(axis) {
|
||||||
state.set(state.get().with_hovered_on_thumb(None));
|
state.set(state.get().with_hovered_on_thumb(None));
|
||||||
cx.notify(view_id);
|
notify = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Move thumb position on dragging
|
// Move thumb position on dragging
|
||||||
@@ -769,11 +827,19 @@ impl Element for Scrollbar {
|
|||||||
if (scroll_handle.offset().y - offset.y).abs() > px(1.)
|
if (scroll_handle.offset().y - offset.y).abs() > px(1.)
|
||||||
|| (scroll_handle.offset().x - offset.x).abs() > px(1.)
|
|| (scroll_handle.offset().x - offset.x).abs() > px(1.)
|
||||||
{
|
{
|
||||||
|
// Limit update rate
|
||||||
|
if state.get().last_update.elapsed() > max_fps_duration {
|
||||||
scroll_handle.set_offset(offset);
|
scroll_handle.set_offset(offset);
|
||||||
cx.notify(view_id);
|
state.set(state.get().with_last_update(Instant::now()));
|
||||||
|
notify = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if notify {
|
||||||
|
cx.notify(view_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
window.on_mouse_event({
|
window.on_mouse_event({
|
||||||
|
|||||||
@@ -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"))]
|
||||||
|
|||||||
@@ -121,7 +121,6 @@ impl RenderOnce for WindowBorder {
|
|||||||
.when(!tiling.bottom, |div| div.pb(SHADOW_SIZE))
|
.when(!tiling.bottom, |div| div.pb(SHADOW_SIZE))
|
||||||
.when(!tiling.left, |div| div.pl(SHADOW_SIZE))
|
.when(!tiling.left, |div| div.pl(SHADOW_SIZE))
|
||||||
.when(!tiling.right, |div| div.pr(SHADOW_SIZE))
|
.when(!tiling.right, |div| div.pr(SHADOW_SIZE))
|
||||||
.on_mouse_move(|_e, window, _cx| window.refresh())
|
|
||||||
.on_mouse_down(MouseButton::Left, move |_, window, _cx| {
|
.on_mouse_down(MouseButton::Left, move |_, window, _cx| {
|
||||||
let size = window.window_bounds().get_bounds().size;
|
let size = window.window_bounds().get_bounds().size;
|
||||||
let pos = window.mouse_position();
|
let pos = window.mouse_position();
|
||||||
|
|||||||
Reference in New Issue
Block a user