Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8192023479 | ||
|
|
4637478a0b | ||
|
|
6b5adb0a56 | ||
|
|
9fd55cf3ff | ||
|
|
14c36e4731 | ||
|
|
a6e00b47d8 | ||
|
|
0784a20be5 | ||
|
|
6023063cf4 | ||
|
|
67c92cb319 | ||
|
|
122299f548 | ||
|
|
d87bcfbd65 | ||
|
|
de5134676d | ||
|
|
512834b640 | ||
|
|
a1a0a7ecd4 | ||
|
|
a4067d2c00 | ||
|
|
4ebe590f8a | ||
|
|
9da624dd0c | ||
|
|
7091fa1cab | ||
|
|
a1bd4954eb | ||
|
|
fde1499796 | ||
|
|
649cdff49c | ||
|
|
b0fa98831d | ||
|
|
b9297d3a01 | ||
|
|
b5ed079a0e | ||
|
|
6017eebaed | ||
|
|
15bbe82a87 | ||
|
|
83687e5448 | ||
|
|
48c90f5bb0 | ||
|
|
47abd2909b | ||
|
|
ac0b233089 | ||
|
|
a1e0934fc3 | ||
|
|
32a0401907 | ||
|
|
1742031901 | ||
|
|
2415374567 | ||
|
|
7fc727461e | ||
|
|
68a8ec7a69 | ||
|
|
b7693444e6 |
Generated
+1241
-1153
File diff suppressed because it is too large
Load Diff
+3
-11
@@ -4,7 +4,7 @@ members = ["crates/*"]
|
|||||||
default-members = ["crates/coop"]
|
default-members = ["crates/coop"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.2.11"
|
version = "0.3.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
publish = false
|
publish = false
|
||||||
|
|
||||||
@@ -22,21 +22,13 @@ 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 = { git = "https://github.com/rust-nostr/nostr" }
|
nostr-lmdb = { git = "https://github.com/rust-nostr/nostr" }
|
||||||
nostr-connect = { git = "https://github.com/rust-nostr/nostr" }
|
nostr-connect = { git = "https://github.com/rust-nostr/nostr" }
|
||||||
nostr-sdk = { git = "https://github.com/rust-nostr/nostr", features = [
|
nostr-sdk = { git = "https://github.com/rust-nostr/nostr", features = [ "nip96", "nip59", "nip49", "nip44" ] }
|
||||||
"lmdb",
|
|
||||||
"nip96",
|
|
||||||
"nip59",
|
|
||||||
"nip49",
|
|
||||||
"nip44",
|
|
||||||
] }
|
|
||||||
|
|
||||||
# Others
|
# Others
|
||||||
anyhow = "1.0.44"
|
anyhow = "1.0.44"
|
||||||
chrono = "0.4.38"
|
chrono = "0.4.38"
|
||||||
dirs = "5.0"
|
|
||||||
emojis = "0.6.4"
|
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
itertools = "0.13.0"
|
itertools = "0.13.0"
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||||
|
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 8.75a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3Zm0 0v6m8.25-2.838v-4.97a2 2 0 0 0-1.367-1.898l-6.25-2.083a2 2 0 0 0-1.265 0l-6.25 2.083A2 2 0 0 0 3.75 6.942v4.97c0 4.973 4.25 7.338 8.25 9.496 4-2.158 8.25-4.523 8.25-9.496Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 425 B |
@@ -0,0 +1,22 @@
|
|||||||
|
[package]
|
||||||
|
name = "account"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
state = { path = "../state" }
|
||||||
|
settings = { path = "../settings" }
|
||||||
|
common = { path = "../common" }
|
||||||
|
theme = { path = "../theme" }
|
||||||
|
ui = { path = "../ui" }
|
||||||
|
|
||||||
|
gpui.workspace = true
|
||||||
|
nostr-sdk.workspace = true
|
||||||
|
|
||||||
|
anyhow.workspace = true
|
||||||
|
smallvec.workspace = true
|
||||||
|
smol.workspace = true
|
||||||
|
log.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use anyhow::Error;
|
||||||
|
use common::BOOTSTRAP_RELAYS;
|
||||||
|
use gpui::{App, AppContext, Context, Entity, Global, Task};
|
||||||
|
use nostr_sdk::prelude::*;
|
||||||
|
use smallvec::{smallvec, SmallVec};
|
||||||
|
use state::NostrRegistry;
|
||||||
|
|
||||||
|
pub fn init(cx: &mut App) {
|
||||||
|
Account::set_global(cx.new(Account::new), cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
struct GlobalAccount(Entity<Account>);
|
||||||
|
|
||||||
|
impl Global for GlobalAccount {}
|
||||||
|
|
||||||
|
pub struct Account {
|
||||||
|
/// The public key of the account
|
||||||
|
public_key: Option<PublicKey>,
|
||||||
|
|
||||||
|
/// Status of the current user NIP-65 relays
|
||||||
|
pub nip65_status: Entity<RelayStatus>,
|
||||||
|
|
||||||
|
/// Status of the current user NIP-17 relays
|
||||||
|
pub nip17_status: Entity<RelayStatus>,
|
||||||
|
|
||||||
|
/// Tasks for asynchronous operations
|
||||||
|
_tasks: SmallVec<[Task<()>; 2]>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
pub enum RelayStatus {
|
||||||
|
#[default]
|
||||||
|
Initial,
|
||||||
|
NotSet,
|
||||||
|
Set,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Account {
|
||||||
|
/// Retrieve the global account state
|
||||||
|
pub fn global(cx: &App) -> Entity<Self> {
|
||||||
|
cx.global::<GlobalAccount>().0.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if the global account state exists
|
||||||
|
pub fn has_global(cx: &App) -> bool {
|
||||||
|
cx.has_global::<GlobalAccount>()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove the global account state
|
||||||
|
pub fn remove_global(cx: &mut App) {
|
||||||
|
cx.remove_global::<GlobalAccount>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the global account instance
|
||||||
|
fn set_global(state: Entity<Self>, cx: &mut App) {
|
||||||
|
cx.set_global(GlobalAccount(state));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new account instance
|
||||||
|
fn new(cx: &mut Context<Self>) -> Self {
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let client = nostr.read(cx).client();
|
||||||
|
|
||||||
|
let nip65_status = cx.new(|_| RelayStatus::default());
|
||||||
|
let nip17_status = cx.new(|_| RelayStatus::default());
|
||||||
|
|
||||||
|
let mut tasks = smallvec![];
|
||||||
|
|
||||||
|
tasks.push(
|
||||||
|
// Observe the nostr signer and set the public key when it sets
|
||||||
|
cx.spawn(async move |this, cx| {
|
||||||
|
let result = cx
|
||||||
|
.background_spawn(async move { Self::observe_signer(&client).await })
|
||||||
|
.await;
|
||||||
|
|
||||||
|
if let Some(public_key) = result {
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.set_account(public_key, cx);
|
||||||
|
})
|
||||||
|
.expect("Entity has been released")
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
public_key: None,
|
||||||
|
nip65_status,
|
||||||
|
nip17_status,
|
||||||
|
_tasks: tasks,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Observe the signer and return the public key when it sets
|
||||||
|
async fn observe_signer(client: &Client) -> Option<PublicKey> {
|
||||||
|
let loop_duration = Duration::from_millis(800);
|
||||||
|
|
||||||
|
loop {
|
||||||
|
if let Ok(signer) = client.signer().await {
|
||||||
|
if let Ok(public_key) = signer.get_public_key().await {
|
||||||
|
// Get current user's gossip relays
|
||||||
|
Self::get_gossip_relays(client, public_key).await.ok()?;
|
||||||
|
|
||||||
|
return Some(public_key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
smol::Timer::after(loop_duration).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get gossip relays for a given public key
|
||||||
|
async fn get_gossip_relays(client: &Client, public_key: PublicKey) -> Result<(), Error> {
|
||||||
|
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
|
||||||
|
|
||||||
|
let filter = Filter::new()
|
||||||
|
.kind(Kind::RelayList)
|
||||||
|
.author(public_key)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
// Subscribe to events from the bootstrapping relays
|
||||||
|
client
|
||||||
|
.subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts))
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ensure the user has NIP-65 relays
|
||||||
|
async fn ensure_nip65_relays(client: &Client, public_key: PublicKey) -> Result<bool, Error> {
|
||||||
|
let filter = Filter::new()
|
||||||
|
.kind(Kind::RelayList)
|
||||||
|
.author(public_key)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
// Count the number of nip65 relays event in the database
|
||||||
|
let total = client.database().count(filter).await.unwrap_or(0);
|
||||||
|
|
||||||
|
Ok(total > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ensure the user has NIP-17 relays
|
||||||
|
async fn ensure_nip17_relays(client: &Client, public_key: PublicKey) -> Result<bool, Error> {
|
||||||
|
let filter = Filter::new()
|
||||||
|
.kind(Kind::InboxRelays)
|
||||||
|
.author(public_key)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
// Count the number of nip17 relays event in the database
|
||||||
|
let total = client.database().count(filter).await.unwrap_or(0);
|
||||||
|
|
||||||
|
Ok(total > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the public key of the account
|
||||||
|
pub fn set_account(&mut self, public_key: PublicKey, cx: &mut Context<Self>) {
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let client = nostr.read(cx).client();
|
||||||
|
|
||||||
|
// Update account's public key
|
||||||
|
self.public_key = Some(public_key);
|
||||||
|
|
||||||
|
// Add background task
|
||||||
|
self._tasks.push(
|
||||||
|
// Verify user's nip65 and nip17 relays
|
||||||
|
cx.spawn(async move |this, cx| {
|
||||||
|
cx.background_executor().timer(Duration::from_secs(5)).await;
|
||||||
|
|
||||||
|
// Fetch the NIP-65 relays event in the local database
|
||||||
|
let ensure_nip65 = Self::ensure_nip65_relays(&client, public_key).await;
|
||||||
|
|
||||||
|
// Fetch the NIP-17 relays event in the local database
|
||||||
|
let ensure_nip17 = Self::ensure_nip17_relays(&client, public_key).await;
|
||||||
|
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.nip65_status.update(cx, |this, cx| {
|
||||||
|
*this = match ensure_nip65 {
|
||||||
|
Ok(true) => RelayStatus::Set,
|
||||||
|
_ => RelayStatus::NotSet,
|
||||||
|
};
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
|
this.nip17_status.update(cx, |this, cx| {
|
||||||
|
*this = match ensure_nip17 {
|
||||||
|
Ok(true) => RelayStatus::Set,
|
||||||
|
_ => RelayStatus::NotSet,
|
||||||
|
};
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.expect("Entity has been released")
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if the account entity has a public key
|
||||||
|
pub fn has_account(&self) -> bool {
|
||||||
|
self.public_key.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the public key of the account
|
||||||
|
pub fn public_key(&self) -> PublicKey {
|
||||||
|
// This method is only called when user is logged in, so unwrap safely
|
||||||
|
self.public_key.unwrap()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ use rust_embed::RustEmbed;
|
|||||||
#[derive(RustEmbed)]
|
#[derive(RustEmbed)]
|
||||||
#[folder = "../../assets"]
|
#[folder = "../../assets"]
|
||||||
#[include = "fonts/**/*"]
|
#[include = "fonts/**/*"]
|
||||||
#[include = "brand/*"]
|
#[include = "brand/**/*"]
|
||||||
#[include = "icons/**/*"]
|
#[include = "icons/**/*"]
|
||||||
#[exclude = "*.DS_Store"]
|
#[exclude = "*.DS_Store"]
|
||||||
pub struct Assets;
|
pub struct Assets;
|
||||||
|
|||||||
@@ -6,13 +6,16 @@ publish.workspace = true
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
common = { path = "../common" }
|
common = { path = "../common" }
|
||||||
global = { path = "../global" }
|
state = { path = "../state" }
|
||||||
|
|
||||||
gpui.workspace = true
|
gpui.workspace = true
|
||||||
|
gpui_tokio.workspace = true
|
||||||
|
reqwest.workspace = true
|
||||||
nostr-sdk.workspace = true
|
nostr-sdk.workspace = true
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
smol.workspace = true
|
smol.workspace = true
|
||||||
log.workspace = true
|
log.workspace = true
|
||||||
smallvec.workspace = true
|
smallvec.workspace = true
|
||||||
|
|
||||||
cargo-packager-updater = "0.2.3"
|
semver = "1.0.27"
|
||||||
|
tempfile = "3.23.0"
|
||||||
|
|||||||
+436
-97
@@ -1,10 +1,22 @@
|
|||||||
use anyhow::Error;
|
use std::ffi::OsString;
|
||||||
use cargo_packager_updater::semver::Version;
|
use std::path::{Path, PathBuf};
|
||||||
use cargo_packager_updater::{check_update, Config, Update};
|
use std::sync::Arc;
|
||||||
use global::constants::{APP_PUBKEY, APP_UPDATER_ENDPOINT};
|
use std::time::Duration;
|
||||||
use gpui::http_client::Url;
|
|
||||||
use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task, Window};
|
use anyhow::{anyhow, Context as AnyhowContext, Error};
|
||||||
|
use common::BOOTSTRAP_RELAYS;
|
||||||
|
use gpui::http_client::{AsyncBody, HttpClient};
|
||||||
|
use gpui::{
|
||||||
|
App, AppContext, AsyncApp, BackgroundExecutor, Context, Entity, Global, Subscription, Task,
|
||||||
|
};
|
||||||
|
use nostr_sdk::prelude::*;
|
||||||
|
use semver::Version;
|
||||||
use smallvec::{smallvec, SmallVec};
|
use smallvec::{smallvec, SmallVec};
|
||||||
|
use smol::fs::File;
|
||||||
|
use smol::process::Command;
|
||||||
|
use state::NostrRegistry;
|
||||||
|
|
||||||
|
const APP_PUBKEY: &str = "npub1y9jvl5vznq49eh9f2gj7679v4042kj80lp7p8fte3ql2cr7hty7qsyca8q";
|
||||||
|
|
||||||
pub fn init(cx: &mut App) {
|
pub fn init(cx: &mut App) {
|
||||||
AutoUpdater::set_global(cx.new(AutoUpdater::new), cx);
|
AutoUpdater::set_global(cx.new(AutoUpdater::new), cx);
|
||||||
@@ -14,16 +26,101 @@ struct GlobalAutoUpdater(Entity<AutoUpdater>);
|
|||||||
|
|
||||||
impl Global for GlobalAutoUpdater {}
|
impl Global for GlobalAutoUpdater {}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
struct InstallerDir(tempfile::TempDir);
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
impl InstallerDir {
|
||||||
|
async fn new() -> Result<Self, Error> {
|
||||||
|
Ok(Self(
|
||||||
|
tempfile::Builder::new()
|
||||||
|
.prefix("coop-auto-update")
|
||||||
|
.tempdir()?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn path(&self) -> &Path {
|
||||||
|
self.0.path()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
struct InstallerDir(PathBuf);
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
impl InstallerDir {
|
||||||
|
async fn new() -> Result<Self, Error> {
|
||||||
|
let installer_dir = std::env::current_exe()?
|
||||||
|
.parent()
|
||||||
|
.context("No parent dir for Coop.exe")?
|
||||||
|
.join("updates");
|
||||||
|
|
||||||
|
if smol::fs::metadata(&installer_dir).await.is_ok() {
|
||||||
|
smol::fs::remove_dir_all(&installer_dir).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
smol::fs::create_dir(&installer_dir).await?;
|
||||||
|
|
||||||
|
Ok(Self(installer_dir))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn path(&self) -> &Path {
|
||||||
|
self.0.as_path()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MacOsUnmounter<'a> {
|
||||||
|
mount_path: PathBuf,
|
||||||
|
background_executor: &'a BackgroundExecutor,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for MacOsUnmounter<'_> {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let mount_path = std::mem::take(&mut self.mount_path);
|
||||||
|
|
||||||
|
self.background_executor
|
||||||
|
.spawn(async move {
|
||||||
|
let unmount_output = Command::new("hdiutil")
|
||||||
|
.args(["detach", "-force"])
|
||||||
|
.arg(&mount_path)
|
||||||
|
.output()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match unmount_output {
|
||||||
|
Ok(output) if output.status.success() => {
|
||||||
|
log::info!("Successfully unmounted the disk image");
|
||||||
|
}
|
||||||
|
Ok(output) => {
|
||||||
|
log::error!(
|
||||||
|
"Failed to unmount disk image: {:?}",
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
log::error!("Error while trying to unmount disk image: {:?}", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
pub enum AutoUpdateStatus {
|
pub enum AutoUpdateStatus {
|
||||||
Idle,
|
Idle,
|
||||||
Checking,
|
Checking,
|
||||||
Checked { update: Box<Update> },
|
Checked { files: Vec<EventId> },
|
||||||
Installing,
|
Installing,
|
||||||
Updated,
|
Updated,
|
||||||
Errored { msg: Box<String> },
|
Errored { msg: Box<String> },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl AsRef<AutoUpdateStatus> for AutoUpdateStatus {
|
||||||
|
fn as_ref(&self) -> &AutoUpdateStatus {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl AutoUpdateStatus {
|
impl AutoUpdateStatus {
|
||||||
pub fn is_updating(&self) -> bool {
|
pub fn is_updating(&self) -> bool {
|
||||||
matches!(self, Self::Checked { .. } | Self::Installing)
|
matches!(self, Self::Checked { .. } | Self::Installing)
|
||||||
@@ -33,10 +130,8 @@ impl AutoUpdateStatus {
|
|||||||
matches!(self, Self::Updated)
|
matches!(self, Self::Updated)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn checked(update: Update) -> Self {
|
pub fn checked(files: Vec<EventId>) -> Self {
|
||||||
Self::Checked {
|
Self::Checked { files }
|
||||||
update: Box::new(update),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn error(e: String) -> Self {
|
pub fn error(e: String) -> Self {
|
||||||
@@ -44,109 +139,89 @@ impl AutoUpdateStatus {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
pub struct AutoUpdater {
|
pub struct AutoUpdater {
|
||||||
|
/// Current status of the auto updater
|
||||||
pub status: AutoUpdateStatus,
|
pub status: AutoUpdateStatus,
|
||||||
config: Config,
|
|
||||||
version: Version,
|
/// Current version of the application
|
||||||
#[allow(dead_code)]
|
pub version: Version,
|
||||||
subscriptions: SmallVec<[Subscription; 1]>,
|
|
||||||
|
/// Event subscriptions
|
||||||
|
_subscriptions: SmallVec<[Subscription; 1]>,
|
||||||
|
|
||||||
|
/// Background tasks
|
||||||
|
_tasks: SmallVec<[Task<()>; 2]>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AutoUpdater {
|
impl AutoUpdater {
|
||||||
/// Retrieve the Global Auto Updater instance
|
/// Retrieve the global auto updater instance
|
||||||
pub fn global(cx: &App) -> Entity<Self> {
|
pub fn global(cx: &App) -> Entity<Self> {
|
||||||
cx.global::<GlobalAutoUpdater>().0.clone()
|
cx.global::<GlobalAutoUpdater>().0.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Retrieve the Auto Updater instance
|
/// Set the global auto updater instance
|
||||||
pub fn read_global(cx: &App) -> &Self {
|
fn set_global(state: Entity<Self>, cx: &mut App) {
|
||||||
cx.global::<GlobalAutoUpdater>().0.read(cx)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set the Global Auto Updater instance
|
|
||||||
pub(crate) fn set_global(state: Entity<Self>, cx: &mut App) {
|
|
||||||
cx.set_global(GlobalAutoUpdater(state));
|
cx.set_global(GlobalAutoUpdater(state));
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn new(cx: &mut Context<Self>) -> Self {
|
fn new(cx: &mut Context<Self>) -> Self {
|
||||||
let config = cargo_packager_updater::Config {
|
let version = Version::parse(env!("CARGO_PKG_VERSION")).unwrap();
|
||||||
endpoints: vec![Url::parse(APP_UPDATER_ENDPOINT).expect("Endpoint is not valid")],
|
let async_version = version.clone();
|
||||||
pubkey: String::from(APP_PUBKEY),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let version = Version::parse(env!("CARGO_PKG_VERSION")).expect("Failed to parse version");
|
|
||||||
let mut subscriptions = smallvec![];
|
|
||||||
|
|
||||||
subscriptions.push(cx.observe_new::<Self>(|this, window, cx| {
|
let mut subscriptions = smallvec![];
|
||||||
if let Some(window) = window {
|
let mut tasks = smallvec![];
|
||||||
this.check_for_updates(window, cx);
|
|
||||||
|
tasks.push(
|
||||||
|
// Subscribe to get the new update event in the bootstrap relays
|
||||||
|
Self::subscribe_to_updates(cx),
|
||||||
|
);
|
||||||
|
|
||||||
|
tasks.push(
|
||||||
|
// Subscribe to get the new update event in the bootstrap relays
|
||||||
|
cx.spawn(async move |this, cx| {
|
||||||
|
// Check for updates after 2 minutes
|
||||||
|
cx.background_executor()
|
||||||
|
.timer(Duration::from_secs(120))
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Update the status to checking
|
||||||
|
_ = this.update(cx, |this, cx| {
|
||||||
|
this.set_status(AutoUpdateStatus::Checking, cx);
|
||||||
|
});
|
||||||
|
|
||||||
|
match Self::check_for_updates(async_version, cx).await {
|
||||||
|
Ok(ids) => {
|
||||||
|
// Update the status to downloading
|
||||||
|
_ = this.update(cx, |this, cx| {
|
||||||
|
this.set_status(AutoUpdateStatus::checked(ids), cx);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}));
|
Err(e) => {
|
||||||
|
_ = this.update(cx, |this, cx| {
|
||||||
|
this.set_status(AutoUpdateStatus::Idle, cx);
|
||||||
|
});
|
||||||
|
|
||||||
|
log::warn!("{e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
subscriptions.push(
|
||||||
|
// Observe the status
|
||||||
|
cx.observe_self(|this, cx| {
|
||||||
|
if let AutoUpdateStatus::Checked { files } = this.status.clone() {
|
||||||
|
this.get_latest_release(&files, cx);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
status: AutoUpdateStatus::Idle,
|
status: AutoUpdateStatus::Idle,
|
||||||
version,
|
version,
|
||||||
config,
|
_subscriptions: subscriptions,
|
||||||
subscriptions,
|
_tasks: tasks,
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn check_for_updates(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
||||||
let config = self.config.clone();
|
|
||||||
let current_version = self.version.clone();
|
|
||||||
|
|
||||||
log::info!("Checking for updates...");
|
|
||||||
self.set_status(AutoUpdateStatus::Checking, cx);
|
|
||||||
|
|
||||||
let checking: Task<Result<Option<Update>, Error>> = cx.background_spawn(async move {
|
|
||||||
if let Some(update) = check_update(current_version, config)? {
|
|
||||||
Ok(Some(update))
|
|
||||||
} else {
|
|
||||||
Ok(None)
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
cx.spawn_in(window, async move |this, cx| {
|
|
||||||
if let Ok(Some(update)) = checking.await {
|
|
||||||
this.update_in(cx, |this, window, cx| {
|
|
||||||
this.set_status(AutoUpdateStatus::checked(update), cx);
|
|
||||||
this.install_update(window, cx);
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
} else {
|
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
this.set_status(AutoUpdateStatus::Idle, cx);
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.detach();
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn install_update(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
||||||
self.set_status(AutoUpdateStatus::Installing, cx);
|
|
||||||
|
|
||||||
if let AutoUpdateStatus::Checked { update } = self.status.clone() {
|
|
||||||
let install: Task<Result<(), Error>> =
|
|
||||||
cx.background_spawn(async move { Ok(update.download_and_install()?) });
|
|
||||||
|
|
||||||
cx.spawn_in(window, async move |this, cx| {
|
|
||||||
match install.await {
|
|
||||||
Ok(_) => {
|
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
this.set_status(AutoUpdateStatus::Updated, cx);
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
this.set_status(AutoUpdateStatus::error(e.to_string()), cx);
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.detach();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,4 +229,268 @@ impl AutoUpdater {
|
|||||||
self.status = status;
|
self.status = status;
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn subscribe_to_updates(cx: &App) -> Task<()> {
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let client = nostr.read(cx).client();
|
||||||
|
|
||||||
|
cx.background_spawn(async move {
|
||||||
|
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
|
||||||
|
let app_pubkey = PublicKey::parse(APP_PUBKEY).unwrap();
|
||||||
|
|
||||||
|
let filter = Filter::new()
|
||||||
|
.kind(Kind::ReleaseArtifactSet)
|
||||||
|
.author(app_pubkey)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if let Err(e) = client
|
||||||
|
.subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts))
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
log::error!("Failed to subscribe to updates: {e}");
|
||||||
|
};
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_for_updates(version: Version, cx: &AsyncApp) -> Task<Result<Vec<EventId>, Error>> {
|
||||||
|
let Ok(client) = cx.update(|cx| {
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
nostr.read(cx).client()
|
||||||
|
}) else {
|
||||||
|
return Task::ready(Err(anyhow!("Entity has been released")));
|
||||||
|
};
|
||||||
|
|
||||||
|
cx.background_spawn(async move {
|
||||||
|
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
|
||||||
|
let app_pubkey = PublicKey::parse(APP_PUBKEY).unwrap();
|
||||||
|
|
||||||
|
let filter = Filter::new()
|
||||||
|
.kind(Kind::ReleaseArtifactSet)
|
||||||
|
.author(app_pubkey)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if let Some(event) = client.database().query(filter).await?.first_owned() {
|
||||||
|
let new_version: Version = event
|
||||||
|
.tags
|
||||||
|
.find(TagKind::d())
|
||||||
|
.and_then(|tag| tag.content())
|
||||||
|
.and_then(|content| content.split("@").last())
|
||||||
|
.and_then(|content| Version::parse(content).ok())
|
||||||
|
.context("Failed to parse version")?;
|
||||||
|
|
||||||
|
if new_version > version {
|
||||||
|
// Get all file metadata event ids
|
||||||
|
let ids: Vec<EventId> = event.tags.event_ids().copied().collect();
|
||||||
|
|
||||||
|
let filter = Filter::new()
|
||||||
|
.kind(Kind::FileMetadata)
|
||||||
|
.author(app_pubkey)
|
||||||
|
.ids(ids.clone());
|
||||||
|
|
||||||
|
// Get all files for this release
|
||||||
|
client
|
||||||
|
.subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts))
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(ids)
|
||||||
|
} else {
|
||||||
|
Err(anyhow!("No update available"))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Err(anyhow!("No update available"))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_latest_release(&mut self, ids: &[EventId], cx: &mut Context<Self>) {
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let client = nostr.read(cx).client();
|
||||||
|
let http_client = cx.http_client();
|
||||||
|
let ids = ids.to_vec();
|
||||||
|
|
||||||
|
let task: Task<Result<(InstallerDir, PathBuf), Error>> = cx.background_spawn(async move {
|
||||||
|
let app_pubkey = PublicKey::parse(APP_PUBKEY).unwrap();
|
||||||
|
let os = std::env::consts::OS;
|
||||||
|
|
||||||
|
let filter = Filter::new()
|
||||||
|
.kind(Kind::FileMetadata)
|
||||||
|
.author(app_pubkey)
|
||||||
|
.ids(ids);
|
||||||
|
|
||||||
|
// Get all urls for this release
|
||||||
|
let events = client.database().query(filter).await?;
|
||||||
|
|
||||||
|
for event in events.into_iter() {
|
||||||
|
// Only process events that match current platform
|
||||||
|
if event.content != os {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the url
|
||||||
|
let url = event
|
||||||
|
.tags
|
||||||
|
.find(TagKind::Url)
|
||||||
|
.and_then(|tag| tag.content())
|
||||||
|
.and_then(|content| Url::parse(content).ok())
|
||||||
|
.context("Failed to parse url")?;
|
||||||
|
|
||||||
|
let installer_dir = InstallerDir::new().await?;
|
||||||
|
let target_path = Self::target_path(&installer_dir).await?;
|
||||||
|
|
||||||
|
// Download the release
|
||||||
|
download(url.as_str(), &target_path, http_client).await?;
|
||||||
|
|
||||||
|
return Ok((installer_dir, target_path));
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(anyhow!("Failed to get latest release"))
|
||||||
|
});
|
||||||
|
|
||||||
|
self._tasks.push(
|
||||||
|
// Install the new release
|
||||||
|
cx.spawn(async move |this, cx| {
|
||||||
|
_ = this.update(cx, |this, cx| {
|
||||||
|
this.set_status(AutoUpdateStatus::Installing, cx);
|
||||||
|
});
|
||||||
|
|
||||||
|
match task.await {
|
||||||
|
Ok((installer_dir, target_path)) => {
|
||||||
|
if Self::install(installer_dir, target_path, cx).await.is_ok() {
|
||||||
|
// Update the status to updated
|
||||||
|
_ = this.update(cx, |this, cx| {
|
||||||
|
this.set_status(AutoUpdateStatus::Updated, cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
// Update the status to error including the error message
|
||||||
|
_ = this.update(cx, |this, cx| {
|
||||||
|
this.set_status(AutoUpdateStatus::error(e.to_string()), cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn target_path(installer_dir: &InstallerDir) -> Result<PathBuf, Error> {
|
||||||
|
let filename = match std::env::consts::OS {
|
||||||
|
"macos" => anyhow::Ok("Coop.dmg"),
|
||||||
|
"windows" => Ok("Coop.exe"),
|
||||||
|
unsupported_os => anyhow::bail!("not supported: {unsupported_os}"),
|
||||||
|
}?;
|
||||||
|
|
||||||
|
Ok(installer_dir.path().join(filename))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn install(
|
||||||
|
installer_dir: InstallerDir,
|
||||||
|
target_path: PathBuf,
|
||||||
|
cx: &AsyncApp,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
match std::env::consts::OS {
|
||||||
|
"macos" => install_release_macos(&installer_dir, target_path, cx).await,
|
||||||
|
"windows" => install_release_windows(target_path).await,
|
||||||
|
unsupported_os => anyhow::bail!("Not supported: {unsupported_os}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn download(
|
||||||
|
url: &str,
|
||||||
|
target_path: &std::path::Path,
|
||||||
|
client: Arc<dyn HttpClient>,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
let body = AsyncBody::default();
|
||||||
|
let mut target_file = File::create(&target_path).await?;
|
||||||
|
let mut response = client.get(url, body, true).await?;
|
||||||
|
|
||||||
|
// Copy the response body to the target file
|
||||||
|
smol::io::copy(response.body_mut(), &mut target_file).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn install_release_macos(
|
||||||
|
temp_dir: &InstallerDir,
|
||||||
|
downloaded_dmg: PathBuf,
|
||||||
|
cx: &AsyncApp,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
let running_app_path = cx.update(|cx| cx.app_path())??;
|
||||||
|
let running_app_filename = running_app_path
|
||||||
|
.file_name()
|
||||||
|
.with_context(|| format!("invalid running app path {running_app_path:?}"))?;
|
||||||
|
|
||||||
|
let mount_path = temp_dir.path().join("Coop");
|
||||||
|
let mut mounted_app_path: OsString = mount_path.join(running_app_filename).into();
|
||||||
|
|
||||||
|
mounted_app_path.push("/");
|
||||||
|
|
||||||
|
let output = Command::new("hdiutil")
|
||||||
|
.args(["attach", "-nobrowse"])
|
||||||
|
.arg(&downloaded_dmg)
|
||||||
|
.arg("-mountroot")
|
||||||
|
.arg(temp_dir.path())
|
||||||
|
.output()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
anyhow::ensure!(
|
||||||
|
output.status.success(),
|
||||||
|
"failed to mount: {:?}",
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create an MacOsUnmounter that will be dropped (and thus unmount the disk) when this function exits
|
||||||
|
let _unmounter = MacOsUnmounter {
|
||||||
|
mount_path: mount_path.clone(),
|
||||||
|
background_executor: cx.background_executor(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let output = Command::new("rsync")
|
||||||
|
.args(["-av", "--delete"])
|
||||||
|
.arg(&mounted_app_path)
|
||||||
|
.arg(&running_app_path)
|
||||||
|
.output()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
anyhow::ensure!(
|
||||||
|
output.status.success(),
|
||||||
|
"failed to copy app: {:?}",
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn install_release_windows(downloaded_installer: PathBuf) -> Result<(), Error> {
|
||||||
|
//const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||||
|
|
||||||
|
let system_root = std::env::var("SYSTEMROOT");
|
||||||
|
let powershell_path = system_root.as_ref().map_or_else(
|
||||||
|
|_| "powershell.exe".to_string(),
|
||||||
|
|p| format!("{p}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"),
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut installer_path = std::ffi::OsString::new();
|
||||||
|
installer_path.push("\"");
|
||||||
|
installer_path.push(&downloaded_installer);
|
||||||
|
installer_path.push("\"");
|
||||||
|
|
||||||
|
let output = Command::new(powershell_path)
|
||||||
|
//.creation_flags(CREATE_NO_WINDOW)
|
||||||
|
.args(["-NoProfile", "-WindowStyle", "Hidden"])
|
||||||
|
.args(["Start-Process"])
|
||||||
|
.arg(installer_path)
|
||||||
|
.arg("-ArgumentList")
|
||||||
|
.args(["/P", "/R"])
|
||||||
|
.output()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
anyhow::ensure!(
|
||||||
|
output.status.success(),
|
||||||
|
"failed to start installer: {:?}",
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
[package]
|
||||||
|
name = "chat"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
common = { path = "../common" }
|
||||||
|
state = { path = "../state" }
|
||||||
|
account = { path = "../account" }
|
||||||
|
encryption = { path = "../encryption" }
|
||||||
|
person = { path = "../person" }
|
||||||
|
settings = { path = "../settings" }
|
||||||
|
|
||||||
|
gpui.workspace = true
|
||||||
|
nostr-sdk.workspace = true
|
||||||
|
|
||||||
|
anyhow.workspace = true
|
||||||
|
itertools.workspace = true
|
||||||
|
smallvec.workspace = true
|
||||||
|
smol.workspace = true
|
||||||
|
log.workspace = true
|
||||||
|
futures.workspace = true
|
||||||
|
flume.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
|
||||||
|
fuzzy-matcher = "0.3.7"
|
||||||
@@ -0,0 +1,736 @@
|
|||||||
|
use std::cmp::Reverse;
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::hash::{DefaultHasher, Hash, Hasher};
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use account::Account;
|
||||||
|
use anyhow::{anyhow, Context as AnyhowContext, Error};
|
||||||
|
use common::{EventUtils, BOOTSTRAP_RELAYS, METADATA_BATCH_LIMIT};
|
||||||
|
use encryption::Encryption;
|
||||||
|
use flume::Sender;
|
||||||
|
use fuzzy_matcher::skim::SkimMatcherV2;
|
||||||
|
use fuzzy_matcher::FuzzyMatcher;
|
||||||
|
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task};
|
||||||
|
pub use message::*;
|
||||||
|
use nostr_sdk::prelude::*;
|
||||||
|
pub use room::*;
|
||||||
|
use settings::AppSettings;
|
||||||
|
use smallvec::{smallvec, SmallVec};
|
||||||
|
use state::{initialized_at, NostrRegistry, GIFTWRAP_SUBSCRIPTION};
|
||||||
|
|
||||||
|
mod message;
|
||||||
|
mod room;
|
||||||
|
|
||||||
|
pub fn init(cx: &mut App) {
|
||||||
|
ChatRegistry::set_global(cx.new(ChatRegistry::new), cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
struct GlobalChatRegistry(Entity<ChatRegistry>);
|
||||||
|
|
||||||
|
impl Global for GlobalChatRegistry {}
|
||||||
|
|
||||||
|
/// Chat Registry
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct ChatRegistry {
|
||||||
|
/// Collection of all chat rooms
|
||||||
|
pub rooms: Vec<Entity<Room>>,
|
||||||
|
|
||||||
|
/// Loading status of the registry
|
||||||
|
pub loading: bool,
|
||||||
|
|
||||||
|
/// Async task for handling notifications
|
||||||
|
handle_notifications: Task<()>,
|
||||||
|
|
||||||
|
/// Event subscriptions
|
||||||
|
_subscriptions: SmallVec<[Subscription; 1]>,
|
||||||
|
|
||||||
|
/// Tasks for asynchronous operations
|
||||||
|
_tasks: SmallVec<[Task<()>; 4]>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
|
pub enum ChatEvent {
|
||||||
|
OpenRoom(u64),
|
||||||
|
CloseRoom(u64),
|
||||||
|
NewChatRequest(RoomKind),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
|
pub enum Signal {
|
||||||
|
Loading(bool),
|
||||||
|
Message(NewMessage),
|
||||||
|
Eose,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EventEmitter<ChatEvent> for ChatRegistry {}
|
||||||
|
|
||||||
|
impl ChatRegistry {
|
||||||
|
/// Retrieve the global chat registry state
|
||||||
|
pub fn global(cx: &App) -> Entity<Self> {
|
||||||
|
cx.global::<GlobalChatRegistry>().0.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the global chat registry instance
|
||||||
|
fn set_global(state: Entity<Self>, cx: &mut App) {
|
||||||
|
cx.set_global(GlobalChatRegistry(state));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new chat registry instance
|
||||||
|
fn new(cx: &mut Context<Self>) -> Self {
|
||||||
|
let encryption = Encryption::global(cx);
|
||||||
|
let encryption_key = encryption.read(cx).encryption.clone();
|
||||||
|
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let client = nostr.read(cx).client();
|
||||||
|
|
||||||
|
let status = Arc::new(AtomicBool::new(true));
|
||||||
|
let (tx, rx) = flume::bounded::<Signal>(2048);
|
||||||
|
|
||||||
|
let handle_notifications = cx.background_spawn({
|
||||||
|
let client = nostr.read(cx).client();
|
||||||
|
let status = Arc::clone(&status);
|
||||||
|
let tx = tx.clone();
|
||||||
|
let signer: Option<Arc<dyn NostrSigner>> = None;
|
||||||
|
|
||||||
|
async move { Self::handle_notifications(&client, &signer, &tx, &status).await }
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut subscriptions = smallvec![];
|
||||||
|
let mut tasks = smallvec![];
|
||||||
|
|
||||||
|
subscriptions.push(
|
||||||
|
// Observe the encryption global state
|
||||||
|
cx.observe(&encryption_key, {
|
||||||
|
let status = Arc::clone(&status);
|
||||||
|
let tx = tx.clone();
|
||||||
|
|
||||||
|
move |this, state, cx| {
|
||||||
|
if let Some(signer) = state.read(cx).clone() {
|
||||||
|
this.handle_notifications = cx.background_spawn({
|
||||||
|
let client = nostr.read(cx).client();
|
||||||
|
let status = Arc::clone(&status);
|
||||||
|
let tx = tx.clone();
|
||||||
|
let signer = Some(signer);
|
||||||
|
|
||||||
|
async move {
|
||||||
|
Self::handle_notifications(&client, &signer, &tx, &status).await
|
||||||
|
}
|
||||||
|
});
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
tasks.push(
|
||||||
|
// Handle unwrapping status
|
||||||
|
cx.background_spawn(
|
||||||
|
async move { Self::handle_unwrapping(&client, &status, &tx).await },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
tasks.push(
|
||||||
|
// Handle new messages
|
||||||
|
cx.spawn(async move |this, cx| {
|
||||||
|
while let Ok(message) = rx.recv_async().await {
|
||||||
|
match message {
|
||||||
|
Signal::Message(message) => {
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.new_message(message, cx);
|
||||||
|
})
|
||||||
|
.expect("Entity has been released");
|
||||||
|
}
|
||||||
|
Signal::Eose => {
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.get_rooms(cx);
|
||||||
|
})
|
||||||
|
.expect("Entity has been released");
|
||||||
|
}
|
||||||
|
Signal::Loading(status) => {
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.set_loading(status, cx);
|
||||||
|
this.get_rooms(cx);
|
||||||
|
})
|
||||||
|
.expect("Entity has been released");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
rooms: vec![],
|
||||||
|
loading: true,
|
||||||
|
handle_notifications,
|
||||||
|
_subscriptions: subscriptions,
|
||||||
|
_tasks: tasks,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_notifications<T>(
|
||||||
|
client: &Client,
|
||||||
|
signer: &Option<T>,
|
||||||
|
tx: &Sender<Signal>,
|
||||||
|
status: &Arc<AtomicBool>,
|
||||||
|
) where
|
||||||
|
T: NostrSigner,
|
||||||
|
{
|
||||||
|
let initialized_at = initialized_at();
|
||||||
|
let subscription_id = SubscriptionId::new(GIFTWRAP_SUBSCRIPTION);
|
||||||
|
|
||||||
|
let mut notifications = client.notifications();
|
||||||
|
let mut public_keys = HashSet::new();
|
||||||
|
let mut processed_events = HashSet::new();
|
||||||
|
|
||||||
|
while let Ok(notification) = notifications.recv().await {
|
||||||
|
let RelayPoolNotification::Message { message, .. } = notification else {
|
||||||
|
// Skip non-message notifications
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
match message {
|
||||||
|
RelayMessage::Event { event, .. } => {
|
||||||
|
if !processed_events.insert(event.id) {
|
||||||
|
// Skip if the event has already been processed
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if event.kind != Kind::GiftWrap {
|
||||||
|
// Skip non-gift wrap events
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract the rumor from the gift wrap event
|
||||||
|
match Self::extract_rumor(client, signer, event.as_ref()).await {
|
||||||
|
Ok(rumor) => {
|
||||||
|
// Get all public keys
|
||||||
|
public_keys.extend(rumor.all_pubkeys());
|
||||||
|
|
||||||
|
let limit_reached = public_keys.len() >= METADATA_BATCH_LIMIT;
|
||||||
|
let done = !status.load(Ordering::Acquire) && !public_keys.is_empty();
|
||||||
|
|
||||||
|
// Get metadata for all public keys if the limit is reached
|
||||||
|
if limit_reached || done {
|
||||||
|
let public_keys = std::mem::take(&mut public_keys);
|
||||||
|
// Get metadata for the public keys
|
||||||
|
Self::get_metadata(client, public_keys).await.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
match &rumor.created_at >= initialized_at {
|
||||||
|
true => {
|
||||||
|
let new_message = NewMessage::new(event.id, rumor);
|
||||||
|
let signal = Signal::Message(new_message);
|
||||||
|
|
||||||
|
if let Err(e) = tx.send_async(signal).await {
|
||||||
|
log::error!("Failed to send signal: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false => {
|
||||||
|
status.store(true, Ordering::Release);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("Failed to unwrap gift wrap event: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RelayMessage::EndOfStoredEvents(id) => {
|
||||||
|
if id.as_ref() == &subscription_id {
|
||||||
|
if let Err(e) = tx.send_async(Signal::Eose).await {
|
||||||
|
log::error!("Failed to send signal: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_unwrapping(client: &Client, status: &Arc<AtomicBool>, tx: &Sender<Signal>) {
|
||||||
|
let loop_duration = Duration::from_secs(20);
|
||||||
|
let mut is_start_processing = false;
|
||||||
|
let mut total_loops = 0;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
if client.has_signer().await {
|
||||||
|
total_loops += 1;
|
||||||
|
|
||||||
|
if status.load(Ordering::Acquire) {
|
||||||
|
is_start_processing = true;
|
||||||
|
|
||||||
|
// Reset gift wrap processing flag
|
||||||
|
_ = status.compare_exchange(true, false, Ordering::Release, Ordering::Relaxed);
|
||||||
|
|
||||||
|
// Send loading signal
|
||||||
|
if let Err(e) = tx.send_async(Signal::Loading(true)).await {
|
||||||
|
log::error!("Failed to send signal: {}", e);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Only run further if we are already processing
|
||||||
|
// Wait until after 2 loops to prevent exiting early while events are still being processed
|
||||||
|
if is_start_processing && total_loops >= 2 {
|
||||||
|
// Send loading signal
|
||||||
|
if let Err(e) = tx.send_async(Signal::Loading(false)).await {
|
||||||
|
log::error!("Failed to send signal: {}", e);
|
||||||
|
}
|
||||||
|
// Reset the counter
|
||||||
|
is_start_processing = false;
|
||||||
|
total_loops = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
smol::Timer::after(loop_duration).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the loading status of the chat registry
|
||||||
|
pub fn set_loading(&mut self, loading: bool, cx: &mut Context<Self>) {
|
||||||
|
self.loading = loading;
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a room by its ID.
|
||||||
|
pub fn room(&self, id: &u64, cx: &App) -> Option<Entity<Room>> {
|
||||||
|
self.rooms
|
||||||
|
.iter()
|
||||||
|
.find(|model| model.read(cx).id == *id)
|
||||||
|
.cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all ongoing rooms.
|
||||||
|
pub fn ongoing_rooms(&self, cx: &App) -> Vec<Entity<Room>> {
|
||||||
|
self.rooms
|
||||||
|
.iter()
|
||||||
|
.filter(|room| room.read(cx).kind == RoomKind::Ongoing)
|
||||||
|
.cloned()
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all request rooms.
|
||||||
|
pub fn request_rooms(&self, cx: &App) -> Vec<Entity<Room>> {
|
||||||
|
self.rooms
|
||||||
|
.iter()
|
||||||
|
.filter(|room| room.read(cx).kind != RoomKind::Ongoing)
|
||||||
|
.cloned()
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a new room to the start of list.
|
||||||
|
pub fn add_room(&mut self, room: Entity<Room>, cx: &mut Context<Self>) {
|
||||||
|
self.rooms.insert(0, room);
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Close a room.
|
||||||
|
pub fn close_room(&mut self, id: u64, cx: &mut Context<Self>) {
|
||||||
|
if self.rooms.iter().any(|r| r.read(cx).id == id) {
|
||||||
|
cx.emit(ChatEvent::CloseRoom(id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sort rooms by their created at.
|
||||||
|
pub fn sort(&mut self, cx: &mut Context<Self>) {
|
||||||
|
self.rooms.sort_by_key(|ev| Reverse(ev.read(cx).created_at));
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Search rooms by their name.
|
||||||
|
pub fn search(&self, query: &str, cx: &App) -> Vec<Entity<Room>> {
|
||||||
|
let matcher = SkimMatcherV2::default();
|
||||||
|
|
||||||
|
self.rooms
|
||||||
|
.iter()
|
||||||
|
.filter(|room| {
|
||||||
|
matcher
|
||||||
|
.fuzzy_match(room.read(cx).display_name(cx).as_ref(), query)
|
||||||
|
.is_some()
|
||||||
|
})
|
||||||
|
.cloned()
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Search rooms by public keys.
|
||||||
|
pub fn search_by_public_key(&self, public_key: PublicKey, cx: &App) -> Vec<Entity<Room>> {
|
||||||
|
self.rooms
|
||||||
|
.iter()
|
||||||
|
.filter(|room| room.read(cx).members.contains(&public_key))
|
||||||
|
.cloned()
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reset the registry.
|
||||||
|
pub fn reset(&mut self, cx: &mut Context<Self>) {
|
||||||
|
self.rooms.clear();
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Push a new room to the chat registry
|
||||||
|
pub fn push_room(&mut self, room: Entity<Room>, cx: &mut Context<Self>) {
|
||||||
|
let id = room.read(cx).id;
|
||||||
|
|
||||||
|
if !self.rooms.iter().any(|r| r.read(cx).id == id) {
|
||||||
|
self.add_room(room, cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
cx.emit(ChatEvent::OpenRoom(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extend the registry with new rooms.
|
||||||
|
fn extend_rooms(&mut self, rooms: HashSet<Room>, cx: &mut Context<Self>) {
|
||||||
|
let mut room_map: HashMap<u64, usize> = self
|
||||||
|
.rooms
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(idx, room)| (room.read(cx).id, idx))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for new_room in rooms.into_iter() {
|
||||||
|
// Check if we already have a room with this ID
|
||||||
|
if let Some(&index) = room_map.get(&new_room.id) {
|
||||||
|
self.rooms[index].update(cx, |this, cx| {
|
||||||
|
if new_room.created_at > this.created_at {
|
||||||
|
*this = new_room;
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
let new_room_id = new_room.id;
|
||||||
|
self.rooms.push(cx.new(|_| new_room));
|
||||||
|
|
||||||
|
let new_index = self.rooms.len();
|
||||||
|
room_map.insert(new_room_id, new_index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load all rooms from the database.
|
||||||
|
pub fn get_rooms(&mut self, cx: &mut Context<Self>) {
|
||||||
|
let task = self.create_get_rooms_task(cx);
|
||||||
|
|
||||||
|
self._tasks.push(
|
||||||
|
// Run and finished in the background
|
||||||
|
cx.spawn(async move |this, cx| {
|
||||||
|
match task.await {
|
||||||
|
Ok(rooms) => {
|
||||||
|
this.update(cx, move |this, cx| {
|
||||||
|
this.extend_rooms(rooms, cx);
|
||||||
|
this.sort(cx);
|
||||||
|
})
|
||||||
|
.expect("Entity has been released");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Failed to load rooms: {e}")
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a task to load rooms from the database
|
||||||
|
fn create_get_rooms_task(&self, cx: &App) -> Task<Result<HashSet<Room>, Error>> {
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let client = nostr.read(cx).client();
|
||||||
|
|
||||||
|
// Get the contact bypass setting
|
||||||
|
let bypass_setting = AppSettings::get_contact_bypass(cx);
|
||||||
|
|
||||||
|
cx.background_spawn(async move {
|
||||||
|
let signer = client.signer().await?;
|
||||||
|
let public_key = signer.get_public_key().await?;
|
||||||
|
let contacts = client.database().contacts_public_keys(public_key).await?;
|
||||||
|
|
||||||
|
let authored_filter = Filter::new()
|
||||||
|
.kind(Kind::ApplicationSpecificData)
|
||||||
|
.custom_tag(SingleLetterTag::lowercase(Alphabet::A), public_key);
|
||||||
|
|
||||||
|
// Get all authored events
|
||||||
|
let authored = client.database().query(authored_filter).await?;
|
||||||
|
|
||||||
|
let addressed_filter = Filter::new()
|
||||||
|
.kind(Kind::ApplicationSpecificData)
|
||||||
|
.custom_tag(SingleLetterTag::lowercase(Alphabet::P), public_key);
|
||||||
|
|
||||||
|
// Get all addressed events
|
||||||
|
let addressed = client.database().query(addressed_filter).await?;
|
||||||
|
|
||||||
|
// Merge authored and addressed events
|
||||||
|
let events = authored.merge(addressed);
|
||||||
|
|
||||||
|
let mut rooms: HashSet<Room> = HashSet::new();
|
||||||
|
let mut grouped: HashMap<u64, Vec<UnsignedEvent>> = HashMap::new();
|
||||||
|
|
||||||
|
// Process each event and group by room hash
|
||||||
|
for raw in events.into_iter() {
|
||||||
|
if let Ok(rumor) = UnsignedEvent::from_json(&raw.content) {
|
||||||
|
if rumor.tags.public_keys().peekable().peek().is_some() {
|
||||||
|
grouped.entry(rumor.uniq_id()).or_default().push(rumor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (_id, mut messages) in grouped.into_iter() {
|
||||||
|
messages.sort_by_key(|m| Reverse(m.created_at));
|
||||||
|
|
||||||
|
let Some(latest) = messages.first() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut room = Room::from(latest);
|
||||||
|
|
||||||
|
if rooms.iter().any(|r| r.id == room.id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut public_keys = room.members();
|
||||||
|
public_keys.retain(|pk| pk != &public_key);
|
||||||
|
|
||||||
|
// Check if the user has responded to the room
|
||||||
|
let user_sent = messages.iter().any(|m| m.pubkey == public_key);
|
||||||
|
|
||||||
|
// Determine if the room is ongoing or not
|
||||||
|
let mut bypassed = false;
|
||||||
|
|
||||||
|
// Check if public keys are from the user's contacts
|
||||||
|
if bypass_setting {
|
||||||
|
bypassed = public_keys.iter().any(|k| contacts.contains(k));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the room's kind based on status
|
||||||
|
if user_sent || bypassed {
|
||||||
|
room = room.kind(RoomKind::Ongoing);
|
||||||
|
}
|
||||||
|
|
||||||
|
rooms.insert(room);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(rooms)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Trigger a refresh of the opened chat rooms by their IDs
|
||||||
|
pub fn refresh_rooms(&mut self, ids: Option<Vec<u64>>, cx: &mut Context<Self>) {
|
||||||
|
if let Some(ids) = ids {
|
||||||
|
for room in self.rooms.iter() {
|
||||||
|
if ids.contains(&room.read(cx).id) {
|
||||||
|
room.update(cx, |this, cx| {
|
||||||
|
this.emit_refresh(cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a Nostr event into a Coop Message and push it to the belonging room
|
||||||
|
///
|
||||||
|
/// If the room doesn't exist, it will be created.
|
||||||
|
/// Updates room ordering based on the most recent messages.
|
||||||
|
pub fn new_message(&mut self, message: NewMessage, cx: &mut Context<Self>) {
|
||||||
|
let id = message.rumor.uniq_id();
|
||||||
|
let author = message.rumor.pubkey;
|
||||||
|
let account = Account::global(cx);
|
||||||
|
|
||||||
|
if let Some(room) = self.rooms.iter().find(|room| room.read(cx).id == id) {
|
||||||
|
let is_new_event = message.rumor.created_at > room.read(cx).created_at;
|
||||||
|
let created_at = message.rumor.created_at;
|
||||||
|
let event_for_emit = message.rumor.clone();
|
||||||
|
|
||||||
|
// Update room
|
||||||
|
room.update(cx, |this, cx| {
|
||||||
|
if is_new_event {
|
||||||
|
this.set_created_at(created_at, cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set this room is ongoing if the new message is from current user
|
||||||
|
if author == account.read(cx).public_key() {
|
||||||
|
this.set_ongoing(cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit the new message to the room
|
||||||
|
this.emit_message(message.gift_wrap, event_for_emit.clone(), cx);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Resort all rooms in the registry by their created at (after updated)
|
||||||
|
if is_new_event {
|
||||||
|
self.sort(cx);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Push the new room to the front of the list
|
||||||
|
self.add_room(cx.new(|_| Room::from(&message.rumor)), cx);
|
||||||
|
|
||||||
|
// Notify the UI about the new room
|
||||||
|
cx.emit(ChatEvent::NewChatRequest(RoomKind::default()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unwraps a gift-wrapped event and processes its contents.
|
||||||
|
async fn extract_rumor<T>(
|
||||||
|
client: &Client,
|
||||||
|
signer: &Option<T>,
|
||||||
|
gift_wrap: &Event,
|
||||||
|
) -> Result<UnsignedEvent, Error>
|
||||||
|
where
|
||||||
|
T: NostrSigner,
|
||||||
|
{
|
||||||
|
// Try to get cached rumor first
|
||||||
|
if let Ok(event) = Self::get_rumor(client, gift_wrap.id).await {
|
||||||
|
return Ok(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to unwrap with the available signer
|
||||||
|
let unwrapped = Self::try_unwrap(client, signer, gift_wrap).await?;
|
||||||
|
let mut rumor_unsigned = unwrapped.rumor;
|
||||||
|
|
||||||
|
// Generate event id for the rumor if it doesn't have one
|
||||||
|
rumor_unsigned.ensure_id();
|
||||||
|
|
||||||
|
// Cache the rumor
|
||||||
|
Self::set_rumor(client, gift_wrap.id, &rumor_unsigned).await?;
|
||||||
|
|
||||||
|
Ok(rumor_unsigned)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper method to try unwrapping with different signers
|
||||||
|
async fn try_unwrap<T>(
|
||||||
|
client: &Client,
|
||||||
|
signer: &Option<T>,
|
||||||
|
gift_wrap: &Event,
|
||||||
|
) -> Result<UnwrappedGift, Error>
|
||||||
|
where
|
||||||
|
T: NostrSigner,
|
||||||
|
{
|
||||||
|
if let Some(custom_signer) = signer.as_ref() {
|
||||||
|
if let Ok(seal) = custom_signer
|
||||||
|
.nip44_decrypt(&gift_wrap.pubkey, &gift_wrap.content)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
let seal: Event = Event::from_json(seal)?;
|
||||||
|
seal.verify_with_ctx(SECP256K1)?;
|
||||||
|
|
||||||
|
// Decrypt the rumor
|
||||||
|
// TODO: verify the sender
|
||||||
|
let rumor = custom_signer
|
||||||
|
.nip44_decrypt(&seal.pubkey, &seal.content)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Construct the unsigned event
|
||||||
|
let rumor = UnsignedEvent::from_json(rumor)?;
|
||||||
|
|
||||||
|
// Return the unwrapped gift
|
||||||
|
return Ok(UnwrappedGift {
|
||||||
|
sender: rumor.pubkey,
|
||||||
|
rumor,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let signer = client.signer().await?;
|
||||||
|
let unwrapped = UnwrappedGift::from_gift_wrap(&signer, gift_wrap).await?;
|
||||||
|
|
||||||
|
Ok(unwrapped)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stores an unwrapped event in local database with reference to original
|
||||||
|
async fn set_rumor(client: &Client, id: EventId, rumor: &UnsignedEvent) -> Result<(), Error> {
|
||||||
|
let rumor_id = rumor.id.context("Rumor is missing an event id")?;
|
||||||
|
let author = rumor.pubkey;
|
||||||
|
let conversation = Self::conversation_id(rumor);
|
||||||
|
|
||||||
|
let mut tags = rumor.tags.clone().to_vec();
|
||||||
|
|
||||||
|
// Add a unique identifier
|
||||||
|
tags.push(Tag::identifier(id));
|
||||||
|
|
||||||
|
// Add a reference to the rumor's author
|
||||||
|
tags.push(Tag::custom(
|
||||||
|
TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::A)),
|
||||||
|
[author],
|
||||||
|
));
|
||||||
|
|
||||||
|
// Add a conversation id
|
||||||
|
tags.push(Tag::custom(
|
||||||
|
TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::C)),
|
||||||
|
[conversation.to_string()],
|
||||||
|
));
|
||||||
|
|
||||||
|
// Add a reference to the rumor's id
|
||||||
|
tags.push(Tag::event(rumor_id));
|
||||||
|
|
||||||
|
// Add references to the rumor's participants
|
||||||
|
for receiver in rumor.tags.public_keys().copied() {
|
||||||
|
tags.push(Tag::custom(
|
||||||
|
TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::P)),
|
||||||
|
[receiver],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert rumor to json
|
||||||
|
let content = rumor.as_json();
|
||||||
|
|
||||||
|
// Construct the event
|
||||||
|
let event = EventBuilder::new(Kind::ApplicationSpecificData, content)
|
||||||
|
.tags(tags)
|
||||||
|
.sign(&Keys::generate())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Save the event to the database
|
||||||
|
client.database().save_event(&event).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieves a previously unwrapped event from local database
|
||||||
|
async fn get_rumor(client: &Client, gift_wrap: EventId) -> Result<UnsignedEvent, Error> {
|
||||||
|
let filter = Filter::new()
|
||||||
|
.kind(Kind::ApplicationSpecificData)
|
||||||
|
.identifier(gift_wrap)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if let Some(event) = client.database().query(filter).await?.first_owned() {
|
||||||
|
UnsignedEvent::from_json(event.content).map_err(|e| anyhow!(e))
|
||||||
|
} else {
|
||||||
|
Err(anyhow!("Event is not cached yet."))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get metadata for a list of public keys
|
||||||
|
async fn get_metadata<I>(client: &Client, public_keys: I) -> Result<(), Error>
|
||||||
|
where
|
||||||
|
I: IntoIterator<Item = PublicKey>,
|
||||||
|
{
|
||||||
|
let authors: Vec<PublicKey> = public_keys.into_iter().collect();
|
||||||
|
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
|
||||||
|
let kinds = vec![Kind::Metadata, Kind::ContactList];
|
||||||
|
|
||||||
|
// Return if the list is empty
|
||||||
|
if authors.is_empty() {
|
||||||
|
return Err(anyhow!("You need at least one public key".to_string(),));
|
||||||
|
}
|
||||||
|
|
||||||
|
let filter = Filter::new()
|
||||||
|
.limit(authors.len() * kinds.len())
|
||||||
|
.authors(authors)
|
||||||
|
.kinds(kinds);
|
||||||
|
|
||||||
|
// Subscribe to filters to the bootstrap relays
|
||||||
|
client
|
||||||
|
.subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts))
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the conversation ID for a given rumor (message).
|
||||||
|
fn conversation_id(rumor: &UnsignedEvent) -> u64 {
|
||||||
|
let mut hasher = DefaultHasher::new();
|
||||||
|
let mut pubkeys: Vec<PublicKey> = rumor.tags.public_keys().copied().collect();
|
||||||
|
pubkeys.push(rumor.pubkey);
|
||||||
|
pubkeys.sort();
|
||||||
|
pubkeys.dedup();
|
||||||
|
pubkeys.hash(&mut hasher);
|
||||||
|
|
||||||
|
hasher.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,18 @@ use std::hash::Hash;
|
|||||||
|
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
|
pub struct NewMessage {
|
||||||
|
pub gift_wrap: EventId,
|
||||||
|
pub rumor: UnsignedEvent,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NewMessage {
|
||||||
|
pub fn new(gift_wrap: EventId, rumor: UnsignedEvent) -> Self {
|
||||||
|
Self { gift_wrap, rumor }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Hash, PartialEq, Eq)]
|
#[derive(Debug, Clone, Hash, PartialEq, Eq)]
|
||||||
pub enum Message {
|
pub enum Message {
|
||||||
User(RenderedMessage),
|
User(RenderedMessage),
|
||||||
@@ -3,16 +3,42 @@ use std::collections::{HashMap, HashSet};
|
|||||||
use std::hash::{Hash, Hasher};
|
use std::hash::{Hash, Hasher};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use account::Account;
|
||||||
use anyhow::{anyhow, Error};
|
use anyhow::{anyhow, Error};
|
||||||
use common::display::RenderedProfile;
|
use common::{EventUtils, RenderedProfile};
|
||||||
use common::event::EventUtils;
|
use encryption::{Encryption, SignerKind};
|
||||||
use global::constants::SEND_RETRY;
|
use gpui::{App, AppContext, Context, EventEmitter, SharedString, Task};
|
||||||
use global::{app_state, nostr_client};
|
|
||||||
use gpui::{App, AppContext, Context, EventEmitter, SharedString, SharedUri, Task};
|
|
||||||
use itertools::Itertools;
|
use itertools::Itertools;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
|
use person::PersonRegistry;
|
||||||
|
use state::NostrRegistry;
|
||||||
|
|
||||||
use crate::Registry;
|
const SEND_RETRY: usize = 10;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
|
pub struct SendOptions {
|
||||||
|
pub backup: bool,
|
||||||
|
pub signer_kind: SignerKind,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SendOptions {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
backup: true,
|
||||||
|
signer_kind: SignerKind::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn backup(&self) -> bool {
|
||||||
|
self.backup
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SendOptions {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct SendReport {
|
pub struct SendReport {
|
||||||
@@ -20,7 +46,9 @@ pub struct SendReport {
|
|||||||
pub status: Option<Output<EventId>>,
|
pub status: Option<Output<EventId>>,
|
||||||
pub error: Option<SharedString>,
|
pub error: Option<SharedString>,
|
||||||
pub on_hold: Option<Event>,
|
pub on_hold: Option<Event>,
|
||||||
|
pub encryption: bool,
|
||||||
pub relays_not_found: bool,
|
pub relays_not_found: bool,
|
||||||
|
pub device_not_found: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SendReport {
|
impl SendReport {
|
||||||
@@ -30,19 +58,19 @@ impl SendReport {
|
|||||||
status: None,
|
status: None,
|
||||||
error: None,
|
error: None,
|
||||||
on_hold: None,
|
on_hold: None,
|
||||||
|
encryption: false,
|
||||||
relays_not_found: false,
|
relays_not_found: false,
|
||||||
|
device_not_found: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn status(mut self, output: Output<EventId>) -> Self {
|
pub fn status(mut self, output: Output<EventId>) -> Self {
|
||||||
self.status = Some(output);
|
self.status = Some(output);
|
||||||
self.relays_not_found = false;
|
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn error(mut self, error: impl Into<SharedString>) -> Self {
|
pub fn error(mut self, error: impl Into<SharedString>) -> Self {
|
||||||
self.error = Some(error.into());
|
self.error = Some(error.into());
|
||||||
self.relays_not_found = false;
|
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,11 +79,21 @@ impl SendReport {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn not_found(mut self) -> Self {
|
pub fn encryption(mut self) -> Self {
|
||||||
|
self.encryption = true;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn relays_not_found(mut self) -> Self {
|
||||||
self.relays_not_found = true;
|
self.relays_not_found = true;
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn device_not_found(mut self) -> Self {
|
||||||
|
self.device_not_found = true;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
pub fn is_relay_error(&self) -> bool {
|
pub fn is_relay_error(&self) -> bool {
|
||||||
self.error.is_some() || self.relays_not_found
|
self.error.is_some() || self.relays_not_found
|
||||||
}
|
}
|
||||||
@@ -71,7 +109,7 @@ impl SendReport {
|
|||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum RoomSignal {
|
pub enum RoomSignal {
|
||||||
NewMessage((EventId, Box<Event>)),
|
NewMessage((EventId, UnsignedEvent)),
|
||||||
Refresh,
|
Refresh,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,10 +122,12 @@ pub enum RoomKind {
|
|||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct Room {
|
pub struct Room {
|
||||||
|
/// Conversation ID
|
||||||
pub id: u64,
|
pub id: u64,
|
||||||
|
/// The timestamp of the last message in the room
|
||||||
pub created_at: Timestamp,
|
pub created_at: Timestamp,
|
||||||
/// Subject of the room
|
/// Subject of the room
|
||||||
pub subject: Option<String>,
|
pub subject: Option<SharedString>,
|
||||||
/// All members of the room
|
/// All members of the room
|
||||||
pub members: Vec<PublicKey>,
|
pub members: Vec<PublicKey>,
|
||||||
/// Kind
|
/// Kind
|
||||||
@@ -122,30 +162,6 @@ impl Eq for Room {}
|
|||||||
|
|
||||||
impl EventEmitter<RoomSignal> for Room {}
|
impl EventEmitter<RoomSignal> for Room {}
|
||||||
|
|
||||||
impl From<&Event> for Room {
|
|
||||||
fn from(val: &Event) -> Self {
|
|
||||||
let id = val.uniq_id();
|
|
||||||
let created_at = val.created_at;
|
|
||||||
|
|
||||||
// Get the members from the event's tags and event's pubkey
|
|
||||||
let members = val.all_pubkeys();
|
|
||||||
|
|
||||||
// Get subject from tags
|
|
||||||
let subject = val
|
|
||||||
.tags
|
|
||||||
.find(TagKind::Subject)
|
|
||||||
.and_then(|tag| tag.content().map(|s| s.to_owned()));
|
|
||||||
|
|
||||||
Room {
|
|
||||||
id,
|
|
||||||
created_at,
|
|
||||||
subject,
|
|
||||||
members,
|
|
||||||
kind: RoomKind::default(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<&UnsignedEvent> for Room {
|
impl From<&UnsignedEvent> for Room {
|
||||||
fn from(val: &UnsignedEvent) -> Self {
|
fn from(val: &UnsignedEvent) -> Self {
|
||||||
let id = val.uniq_id();
|
let id = val.uniq_id();
|
||||||
@@ -158,7 +174,7 @@ impl From<&UnsignedEvent> for Room {
|
|||||||
let subject = val
|
let subject = val
|
||||||
.tags
|
.tags
|
||||||
.find(TagKind::Subject)
|
.find(TagKind::Subject)
|
||||||
.and_then(|tag| tag.content().map(|s| s.to_owned()));
|
.and_then(|tag| tag.content().map(|s| s.to_owned().into()));
|
||||||
|
|
||||||
Room {
|
Room {
|
||||||
id,
|
id,
|
||||||
@@ -171,16 +187,8 @@ impl From<&UnsignedEvent> for Room {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Room {
|
impl Room {
|
||||||
/// Constructs a new room instance for a private message with the given receiver and tags.
|
/// Constructs a new room with the given receiver and tags.
|
||||||
pub async fn new(subject: Option<String>, receivers: Vec<PublicKey>) -> Result<Self, Error> {
|
pub fn new(subject: Option<String>, author: PublicKey, receivers: Vec<PublicKey>) -> Self {
|
||||||
let client = nostr_client();
|
|
||||||
let signer = client.signer().await?;
|
|
||||||
let public_key = signer.get_public_key().await?;
|
|
||||||
|
|
||||||
if receivers.is_empty() {
|
|
||||||
return Err(anyhow!("You need to add at least one receiver"));
|
|
||||||
};
|
|
||||||
|
|
||||||
// Convert receiver's public keys into tags
|
// Convert receiver's public keys into tags
|
||||||
let mut tags: Tags = Tags::from_list(
|
let mut tags: Tags = Tags::from_list(
|
||||||
receivers
|
receivers
|
||||||
@@ -198,12 +206,12 @@ impl Room {
|
|||||||
|
|
||||||
let mut event = EventBuilder::new(Kind::PrivateDirectMessage, "")
|
let mut event = EventBuilder::new(Kind::PrivateDirectMessage, "")
|
||||||
.tags(tags)
|
.tags(tags)
|
||||||
.build(public_key);
|
.build(author);
|
||||||
|
|
||||||
// Generate event ID
|
// Generate event ID
|
||||||
event.ensure_id();
|
event.ensure_id();
|
||||||
|
|
||||||
Ok(Room::from(&event))
|
Room::from(&event)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sets the kind of the room and returns the modified room
|
/// Sets the kind of the room and returns the modified room
|
||||||
@@ -227,14 +235,17 @@ impl Room {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Updates the subject of the room
|
/// Updates the subject of the room
|
||||||
pub fn set_subject(&mut self, subject: String, cx: &mut Context<Self>) {
|
pub fn set_subject<T>(&mut self, subject: T, cx: &mut Context<Self>)
|
||||||
self.subject = Some(subject);
|
where
|
||||||
|
T: Into<SharedString>,
|
||||||
|
{
|
||||||
|
self.subject = Some(subject.into());
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the members of the room
|
/// Returns the members of the room
|
||||||
pub fn members(&self) -> &Vec<PublicKey> {
|
pub fn members(&self) -> Vec<PublicKey> {
|
||||||
&self.members
|
self.members.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Checks if the room has more than two members (group)
|
/// Checks if the room has more than two members (group)
|
||||||
@@ -244,48 +255,49 @@ impl Room {
|
|||||||
|
|
||||||
/// Gets the display name for the room
|
/// Gets the display name for the room
|
||||||
pub fn display_name(&self, cx: &App) -> SharedString {
|
pub fn display_name(&self, cx: &App) -> SharedString {
|
||||||
if let Some(subject) = self.subject.clone() {
|
if let Some(value) = self.subject.clone() {
|
||||||
SharedString::from(subject)
|
value
|
||||||
} else {
|
} else {
|
||||||
self.merged_name(cx)
|
self.merged_name(cx)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Gets the display image for the room
|
/// Gets the display image for the room
|
||||||
pub fn display_image(&self, proxy: bool, cx: &App) -> SharedUri {
|
pub fn display_image(&self, proxy: bool, cx: &App) -> SharedString {
|
||||||
if !self.is_group() {
|
if !self.is_group() {
|
||||||
self.display_member(cx).avatar(proxy)
|
self.display_member(cx).avatar(proxy)
|
||||||
} else {
|
} else {
|
||||||
SharedUri::from("brand/group.png")
|
SharedString::from("brand/group.png")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get a single member to represent the room
|
/// Get a member to represent the room
|
||||||
///
|
///
|
||||||
/// This member is always different from the current user.
|
/// Display member is always different from the current user.
|
||||||
fn display_member(&self, cx: &App) -> Profile {
|
pub fn display_member(&self, cx: &App) -> Profile {
|
||||||
let registry = Registry::read_global(cx);
|
let persons = PersonRegistry::global(cx);
|
||||||
|
let account = Account::global(cx);
|
||||||
|
let public_key = account.read(cx).public_key();
|
||||||
|
|
||||||
if let Some(public_key) = registry.signer_pubkey() {
|
let target_member = self
|
||||||
for member in self.members() {
|
.members
|
||||||
if member != &public_key {
|
.iter()
|
||||||
return registry.get_person(member, cx);
|
.find(|&member| member != &public_key)
|
||||||
}
|
.or_else(|| self.members.first())
|
||||||
}
|
.expect("Room should have at least one member");
|
||||||
}
|
|
||||||
|
|
||||||
registry.get_person(&self.members[0], cx)
|
persons.read(cx).get_person(target_member, cx)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Merge the names of the first two members of the room.
|
/// Merge the names of the first two members of the room.
|
||||||
fn merged_name(&self, cx: &App) -> SharedString {
|
fn merged_name(&self, cx: &App) -> SharedString {
|
||||||
let registry = Registry::read_global(cx);
|
let persons = PersonRegistry::global(cx);
|
||||||
|
|
||||||
if self.is_group() {
|
if self.is_group() {
|
||||||
let profiles: Vec<Profile> = self
|
let profiles: Vec<Profile> = self
|
||||||
.members
|
.members
|
||||||
.iter()
|
.iter()
|
||||||
.map(|public_key| registry.get_person(public_key, cx))
|
.map(|public_key| persons.read(cx).get_person(public_key, cx))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let mut name = profiles
|
let mut name = profiles
|
||||||
@@ -305,130 +317,9 @@ impl Room {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Connects to all members's messaging relays
|
|
||||||
pub fn connect(&self, cx: &App) -> Task<Result<HashMap<PublicKey, Vec<RelayUrl>>, Error>> {
|
|
||||||
let members = self.members.clone();
|
|
||||||
|
|
||||||
cx.background_spawn(async move {
|
|
||||||
let client = nostr_client();
|
|
||||||
let signer = client.signer().await?;
|
|
||||||
let public_key = signer.get_public_key().await?;
|
|
||||||
|
|
||||||
let mut relays = HashMap::new();
|
|
||||||
let mut processed = HashSet::new();
|
|
||||||
|
|
||||||
for member in members.into_iter() {
|
|
||||||
if member == public_key {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
|
|
||||||
relays.insert(member, vec![]);
|
|
||||||
|
|
||||||
let filter = Filter::new()
|
|
||||||
.kind(Kind::InboxRelays)
|
|
||||||
.author(member)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
let mut stream = client
|
|
||||||
.stream_events(filter, Duration::from_secs(10))
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if let Some(event) = stream.next().await {
|
|
||||||
if processed.insert(event.id) {
|
|
||||||
let public_key = event.pubkey;
|
|
||||||
let urls: Vec<RelayUrl> = nip17::extract_owned_relay_list(event).collect();
|
|
||||||
|
|
||||||
// Check if at least one URL exists
|
|
||||||
if urls.is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Connect to relays
|
|
||||||
for url in urls.iter() {
|
|
||||||
client.add_relay(url).await?;
|
|
||||||
client.connect_relay(url).await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
relays.entry(public_key).and_modify(|v| v.extend(urls));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(relays)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn disconnect(&self, relays: Vec<RelayUrl>, cx: &App) -> Task<Result<(), Error>> {
|
|
||||||
cx.background_spawn(async move {
|
|
||||||
let client = nostr_client();
|
|
||||||
|
|
||||||
for relay in relays.into_iter() {
|
|
||||||
client.disconnect_relay(relay).await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Loads all messages for this room from the database
|
|
||||||
pub fn load_messages(&self, cx: &App) -> Task<Result<Vec<Event>, Error>> {
|
|
||||||
let members = self.members.clone();
|
|
||||||
|
|
||||||
cx.background_spawn(async move {
|
|
||||||
let client = nostr_client();
|
|
||||||
let signer = client.signer().await?;
|
|
||||||
let public_key = signer.get_public_key().await?;
|
|
||||||
let sent_ids = app_state()
|
|
||||||
.sent_ids
|
|
||||||
.read()
|
|
||||||
.await
|
|
||||||
.iter()
|
|
||||||
.copied()
|
|
||||||
.collect_vec();
|
|
||||||
|
|
||||||
// Get seen events from database
|
|
||||||
let filter = Filter::new()
|
|
||||||
.kind(Kind::ApplicationSpecificData)
|
|
||||||
.identifiers(sent_ids);
|
|
||||||
|
|
||||||
let seen_events = client.database().query(filter).await?;
|
|
||||||
|
|
||||||
// Extract seen event IDs
|
|
||||||
let seen_ids: Vec<EventId> = seen_events
|
|
||||||
.into_iter()
|
|
||||||
.filter_map(|event| event.tags.event_ids().next().copied())
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
// Get events that sent by current user
|
|
||||||
let filter = Filter::new()
|
|
||||||
.kind(Kind::PrivateDirectMessage)
|
|
||||||
.author(public_key)
|
|
||||||
.pubkeys(members.clone());
|
|
||||||
|
|
||||||
let sent_events = client.database().query(filter).await?;
|
|
||||||
|
|
||||||
// Get events that received by current user
|
|
||||||
let filter = Filter::new()
|
|
||||||
.kind(Kind::PrivateDirectMessage)
|
|
||||||
.authors(members)
|
|
||||||
.pubkey(public_key);
|
|
||||||
|
|
||||||
let recv_events = client.database().query(filter).await?;
|
|
||||||
|
|
||||||
// Merge events
|
|
||||||
let events: Vec<Event> = sent_events
|
|
||||||
.merge(recv_events)
|
|
||||||
.into_iter()
|
|
||||||
.filter(|event| !seen_ids.contains(&event.id))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
Ok(events)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Emits a new message signal to the current room
|
/// Emits a new message signal to the current room
|
||||||
pub fn emit_message(&self, gift_wrap_id: EventId, event: Event, cx: &mut Context<Self>) {
|
pub fn emit_message(&self, id: EventId, event: UnsignedEvent, cx: &mut Context<Self>) {
|
||||||
cx.emit(RoomSignal::NewMessage((gift_wrap_id, Box::new(event))));
|
cx.emit(RoomSignal::NewMessage((id, event)));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Emits a signal to refresh the current room's messages.
|
/// Emits a signal to refresh the current room's messages.
|
||||||
@@ -436,9 +327,68 @@ impl Room {
|
|||||||
cx.emit(RoomSignal::Refresh);
|
cx.emit(RoomSignal::Refresh);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get gossip relays for each member
|
||||||
|
pub fn connect(&self, cx: &App) -> Task<Result<(), Error>> {
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let client = nostr.read(cx).client();
|
||||||
|
let members = self.members();
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
for member in members.into_iter() {
|
||||||
|
if member == public_key {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Construct a filter for gossip relays
|
||||||
|
let filter = Filter::new().kind(Kind::RelayList).author(member).limit(1);
|
||||||
|
|
||||||
|
// Subscribe to get member's gossip relays
|
||||||
|
client.subscribe(filter, Some(opts)).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all messages belonging to the room
|
||||||
|
pub fn get_messages(&self, cx: &App) -> Task<Result<Vec<UnsignedEvent>, Error>> {
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let client = nostr.read(cx).client();
|
||||||
|
let conversation_id = self.id.to_string();
|
||||||
|
|
||||||
|
cx.background_spawn(async move {
|
||||||
|
let filter = Filter::new()
|
||||||
|
.kind(Kind::ApplicationSpecificData)
|
||||||
|
.custom_tag(SingleLetterTag::lowercase(Alphabet::C), conversation_id);
|
||||||
|
|
||||||
|
let messages = client
|
||||||
|
.database()
|
||||||
|
.query(filter)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|event| UnsignedEvent::from_json(&event.content).ok())
|
||||||
|
.sorted_by_key(|message| message.created_at)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(messages)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Create a new message event (unsigned)
|
/// Create a new message event (unsigned)
|
||||||
pub fn create_message(&self, content: &str, replies: &[EventId], cx: &App) -> UnsignedEvent {
|
pub fn create_message(&self, content: &str, replies: &[EventId], cx: &App) -> UnsignedEvent {
|
||||||
let public_key = Registry::read_global(cx).signer_pubkey().unwrap();
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let gossip = nostr.read(cx).gossip();
|
||||||
|
let read_gossip = gossip.read_blocking();
|
||||||
|
|
||||||
|
// Get current user
|
||||||
|
let account = Account::global(cx);
|
||||||
|
let public_key = account.read(cx).public_key();
|
||||||
|
|
||||||
|
// Get room's subject
|
||||||
let subject = self.subject.clone();
|
let subject = self.subject.clone();
|
||||||
|
|
||||||
let mut tags = vec![];
|
let mut tags = vec![];
|
||||||
@@ -447,13 +397,24 @@ impl Room {
|
|||||||
//
|
//
|
||||||
// NOTE: current user will be removed from the list of receivers
|
// NOTE: current user will be removed from the list of receivers
|
||||||
for member in self.members.iter() {
|
for member in self.members.iter() {
|
||||||
tags.push(Tag::public_key(member.to_owned()));
|
// Get relay hint if available
|
||||||
|
let relay_url = read_gossip.messaging_relays(member).first().cloned();
|
||||||
|
|
||||||
|
// Construct a public key tag with relay hint
|
||||||
|
let tag = TagStandard::PublicKey {
|
||||||
|
public_key: member.to_owned(),
|
||||||
|
relay_url,
|
||||||
|
alias: None,
|
||||||
|
uppercase: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
tags.push(Tag::from_standardized_without_cell(tag));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add subject tag if it's present
|
// Add subject tag if it's present
|
||||||
if let Some(subject) = subject {
|
if let Some(value) = subject {
|
||||||
tags.push(Tag::from_standardized_without_cell(TagStandard::Subject(
|
tags.push(Tag::from_standardized_without_cell(TagStandard::Subject(
|
||||||
subject,
|
value.to_string(),
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -462,22 +423,23 @@ impl Room {
|
|||||||
tags.push(Tag::event(replies[0]))
|
tags.push(Tag::event(replies[0]))
|
||||||
} else {
|
} else {
|
||||||
for id in replies {
|
for id in replies {
|
||||||
tags.push(Tag::from_standardized_without_cell(TagStandard::Quote {
|
let tag = TagStandard::Quote {
|
||||||
event_id: id.to_owned(),
|
event_id: id.to_owned(),
|
||||||
relay_url: None,
|
relay_url: None,
|
||||||
public_key: None,
|
public_key: None,
|
||||||
}))
|
};
|
||||||
|
tags.push(Tag::from_standardized_without_cell(tag))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Construct a direct message event
|
// Construct a direct message event
|
||||||
//
|
//
|
||||||
// WARNING: never send this event to relays
|
// WARNING: never sign and send this event to relays
|
||||||
let mut event = EventBuilder::new(Kind::PrivateDirectMessage, content)
|
let mut event = EventBuilder::new(Kind::PrivateDirectMessage, content)
|
||||||
.tags(tags)
|
.tags(tags)
|
||||||
.build(public_key);
|
.build(public_key);
|
||||||
|
|
||||||
// Generate event ID
|
// Ensure the event id has been generated
|
||||||
event.ensure_id();
|
event.ensure_id();
|
||||||
|
|
||||||
event
|
event
|
||||||
@@ -486,43 +448,94 @@ impl Room {
|
|||||||
/// Create a task to send a message to all room members
|
/// Create a task to send a message to all room members
|
||||||
pub fn send_message(
|
pub fn send_message(
|
||||||
&self,
|
&self,
|
||||||
rumor: UnsignedEvent,
|
rumor: &UnsignedEvent,
|
||||||
backup: bool,
|
opts: &SendOptions,
|
||||||
cx: &App,
|
cx: &App,
|
||||||
) -> Task<Result<Vec<SendReport>, Error>> {
|
) -> Task<Result<Vec<SendReport>, Error>> {
|
||||||
let mut members = self.members.clone();
|
let encryption = Encryption::global(cx);
|
||||||
|
let encryption_key = encryption.read(cx).encryption_key(cx);
|
||||||
|
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let client = nostr.read(cx).client();
|
||||||
|
let gossip = nostr.read(cx).gossip();
|
||||||
|
let tracker = nostr.read(cx).tracker();
|
||||||
|
|
||||||
|
let rumor = rumor.to_owned();
|
||||||
|
let opts = opts.to_owned();
|
||||||
|
|
||||||
|
// Get all members
|
||||||
|
let mut members = self.members();
|
||||||
|
|
||||||
cx.background_spawn(async move {
|
cx.background_spawn(async move {
|
||||||
let app_state = app_state();
|
let signer_kind = opts.signer_kind;
|
||||||
let client = nostr_client();
|
let gossip = gossip.read().await;
|
||||||
let signer = client.signer().await?;
|
|
||||||
let public_key = signer.get_public_key().await?;
|
|
||||||
|
|
||||||
// Remove the current user's public key from the list of receivers
|
// Get current user's signer and public key
|
||||||
// Current user will be handled separately
|
let user_signer = client.signer().await?;
|
||||||
members.retain(|&pk| pk != public_key);
|
let user_pubkey = user_signer.get_public_key().await?;
|
||||||
|
|
||||||
let mut reports: Vec<SendReport> = vec![];
|
// Get the encryption public key
|
||||||
|
let encryption_pubkey = if let Some(signer) = encryption_key.as_ref() {
|
||||||
for receiver in members.into_iter() {
|
signer.get_public_key().await.ok()
|
||||||
let rumor = rumor.clone();
|
} else {
|
||||||
let event = EventBuilder::gift_wrap(&signer, &receiver, rumor, vec![]).await?;
|
None
|
||||||
|
|
||||||
let Ok(relay_urls) = Self::messaging_relays(receiver).await else {
|
|
||||||
reports.push(SendReport::new(receiver).not_found());
|
|
||||||
continue;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
match client.send_event_to(relay_urls, &event).await {
|
// Remove the current user's public key from the list of receivers
|
||||||
|
// the current user will be handled separately
|
||||||
|
members.retain(|&pk| pk != user_pubkey);
|
||||||
|
|
||||||
|
// Determine the signer will be used based on the provided options
|
||||||
|
let signer = Self::select_signer(&opts.signer_kind, user_signer, encryption_key)?;
|
||||||
|
|
||||||
|
// Collect the send reports
|
||||||
|
let mut reports: Vec<SendReport> = vec![];
|
||||||
|
|
||||||
|
for member in members.into_iter() {
|
||||||
|
// Get user's messaging relays
|
||||||
|
let urls = gossip.messaging_relays(&member);
|
||||||
|
// Get user's encryption public key if available
|
||||||
|
let encryption = gossip.announcement(&member).map(|a| a.public_key());
|
||||||
|
|
||||||
|
// Check if there are any relays to send the message to
|
||||||
|
if urls.is_empty() {
|
||||||
|
reports.push(SendReport::new(member).relays_not_found());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip sending if using encryption signer but receiver's encryption keys not found
|
||||||
|
if encryption.is_none() && matches!(signer_kind, SignerKind::Encryption) {
|
||||||
|
reports.push(SendReport::new(member).device_not_found());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure connections to the relays
|
||||||
|
gossip.ensure_connections(&client, &urls).await;
|
||||||
|
|
||||||
|
// Determine the receiver based on the signer kind
|
||||||
|
let receiver = Self::select_receiver(&signer_kind, member, encryption)?;
|
||||||
|
|
||||||
|
// Construct the gift wrap event
|
||||||
|
let event = EventBuilder::gift_wrap(
|
||||||
|
&signer,
|
||||||
|
&receiver,
|
||||||
|
rumor.clone(),
|
||||||
|
vec![Tag::public_key(member)],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Send the gift wrap event to the messaging relays
|
||||||
|
match client.send_event_to(urls, &event).await {
|
||||||
Ok(output) => {
|
Ok(output) => {
|
||||||
let id = output.id().to_owned();
|
let id = output.id().to_owned();
|
||||||
let auth_required = output.failed.iter().any(|m| m.1.starts_with("auth-"));
|
let auth = output.failed.iter().any(|(_, s)| s.starts_with("auth-"));
|
||||||
let report = SendReport::new(receiver).status(output);
|
let report = SendReport::new(receiver).status(output);
|
||||||
|
|
||||||
if auth_required {
|
if auth {
|
||||||
// Wait for authenticated and resent event successfully
|
// Wait for authenticated and resent event successfully
|
||||||
for attempt in 0..=SEND_RETRY {
|
for attempt in 0..=SEND_RETRY {
|
||||||
let ids = app_state.resent_ids.read().await;
|
let tracker = tracker.read().await;
|
||||||
|
let ids = tracker.resent_ids();
|
||||||
|
|
||||||
// Check if event was successfully resent
|
// Check if event was successfully resent
|
||||||
if let Some(output) = ids.iter().find(|e| e.id() == &id).cloned() {
|
if let Some(output) = ids.iter().find(|e| e.id() == &id).cloned() {
|
||||||
@@ -549,26 +562,55 @@ impl Room {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Construct a gift wrap to back up to current user's owned messaging relays
|
// Return early if the user disabled backup.
|
||||||
let rumor = rumor.clone();
|
//
|
||||||
let event = EventBuilder::gift_wrap(&signer, &public_key, rumor, vec![]).await?;
|
// Coop will not send a gift wrap event to the current user.
|
||||||
|
if !opts.backup() {
|
||||||
|
return Ok(reports);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip sending if using encryption signer but receiver's encryption keys not found
|
||||||
|
if encryption_pubkey.is_none() && matches!(signer_kind, SignerKind::Encryption) {
|
||||||
|
reports.push(SendReport::new(user_pubkey).device_not_found());
|
||||||
|
return Ok(reports);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine the receiver based on the signer kind
|
||||||
|
let receiver = Self::select_receiver(&signer_kind, user_pubkey, encryption_pubkey)?;
|
||||||
|
|
||||||
|
// Construct the gift-wrapped event
|
||||||
|
let event = EventBuilder::gift_wrap(
|
||||||
|
&signer,
|
||||||
|
&receiver,
|
||||||
|
rumor.clone(),
|
||||||
|
vec![Tag::public_key(user_pubkey)],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
// Only send a backup message to current user if sent successfully to others
|
// Only send a backup message to current user if sent successfully to others
|
||||||
if reports.iter().all(|r| r.is_sent_success()) && backup {
|
if reports.iter().all(|r| r.is_sent_success()) {
|
||||||
if let Ok(relay_urls) = Self::messaging_relays(public_key).await {
|
let urls = gossip.messaging_relays(&user_pubkey);
|
||||||
match client.send_event_to(relay_urls, &event).await {
|
|
||||||
|
// Check if there are any relays to send the event to
|
||||||
|
if urls.is_empty() {
|
||||||
|
reports.push(SendReport::new(user_pubkey).relays_not_found());
|
||||||
|
return Ok(reports);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure connections to the relays
|
||||||
|
gossip.ensure_connections(&client, &urls).await;
|
||||||
|
|
||||||
|
// Send the event to the messaging relays
|
||||||
|
match client.send_event_to(urls, &event).await {
|
||||||
Ok(output) => {
|
Ok(output) => {
|
||||||
reports.push(SendReport::new(public_key).status(output));
|
reports.push(SendReport::new(user_pubkey).status(output));
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
reports.push(SendReport::new(public_key).error(e.to_string()));
|
reports.push(SendReport::new(user_pubkey).error(e.to_string()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
reports.push(SendReport::new(public_key).not_found());
|
reports.push(SendReport::new(user_pubkey).on_hold(event));
|
||||||
}
|
|
||||||
} else {
|
|
||||||
reports.push(SendReport::new(public_key).on_hold(event));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(reports)
|
Ok(reports)
|
||||||
@@ -581,8 +623,12 @@ impl Room {
|
|||||||
reports: Vec<SendReport>,
|
reports: Vec<SendReport>,
|
||||||
cx: &App,
|
cx: &App,
|
||||||
) -> Task<Result<Vec<SendReport>, Error>> {
|
) -> Task<Result<Vec<SendReport>, Error>> {
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let client = nostr.read(cx).client();
|
||||||
|
let gossip = nostr.read(cx).gossip();
|
||||||
|
|
||||||
cx.background_spawn(async move {
|
cx.background_spawn(async move {
|
||||||
let client = nostr_client();
|
let gossip = gossip.read().await;
|
||||||
let mut resend_reports = vec![];
|
let mut resend_reports = vec![];
|
||||||
|
|
||||||
for report in reports.into_iter() {
|
for report in reports.into_iter() {
|
||||||
@@ -611,8 +657,17 @@ impl Room {
|
|||||||
|
|
||||||
// Process the on hold event if it exists
|
// Process the on hold event if it exists
|
||||||
if let Some(event) = report.on_hold {
|
if let Some(event) = report.on_hold {
|
||||||
if let Ok(relay_urls) = Self::messaging_relays(receiver).await {
|
let urls = gossip.messaging_relays(&receiver);
|
||||||
match client.send_event_to(relay_urls, &event).await {
|
|
||||||
|
// Check if there are any relays to send the event to
|
||||||
|
if urls.is_empty() {
|
||||||
|
resend_reports.push(SendReport::new(receiver).relays_not_found());
|
||||||
|
} else {
|
||||||
|
// Ensure connections to the relays
|
||||||
|
gossip.ensure_connections(&client, &urls).await;
|
||||||
|
|
||||||
|
// Send the event to the messaging relays
|
||||||
|
match client.send_event_to(urls, &event).await {
|
||||||
Ok(output) => {
|
Ok(output) => {
|
||||||
resend_reports.push(SendReport::new(receiver).status(output));
|
resend_reports.push(SendReport::new(receiver).status(output));
|
||||||
}
|
}
|
||||||
@@ -620,8 +675,6 @@ impl Room {
|
|||||||
resend_reports.push(SendReport::new(receiver).error(e.to_string()));
|
resend_reports.push(SendReport::new(receiver).error(e.to_string()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
resend_reports.push(SendReport::new(receiver).not_found());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -630,35 +683,30 @@ impl Room {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Gets messaging relays for public key
|
fn select_signer<T>(kind: &SignerKind, user: T, encryption: Option<T>) -> Result<T, Error>
|
||||||
async fn messaging_relays(public_key: PublicKey) -> Result<Vec<RelayUrl>, Error> {
|
where
|
||||||
let client = nostr_client();
|
T: NostrSigner,
|
||||||
let mut relay_urls = vec![];
|
{
|
||||||
|
match kind {
|
||||||
let filter = Filter::new()
|
SignerKind::Encryption => {
|
||||||
.kind(Kind::InboxRelays)
|
Ok(encryption.ok_or_else(|| anyhow!("No encryption key found"))?)
|
||||||
.author(public_key)
|
}
|
||||||
.limit(1);
|
SignerKind::User => Ok(user),
|
||||||
|
SignerKind::Auto => Ok(encryption.unwrap_or(user)),
|
||||||
if let Some(event) = client.database().query(filter).await?.first_owned() {
|
}
|
||||||
let urls: Vec<RelayUrl> = nip17::extract_owned_relay_list(event).collect();
|
|
||||||
|
|
||||||
// Check if at least one URL exists
|
|
||||||
if urls.is_empty() {
|
|
||||||
return Err(anyhow!("Not found"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Connect to relays
|
fn select_receiver(
|
||||||
for url in urls.iter() {
|
kind: &SignerKind,
|
||||||
client.add_relay(url).await?;
|
member: PublicKey,
|
||||||
client.connect_relay(url).await?;
|
encryption: Option<PublicKey>,
|
||||||
|
) -> Result<PublicKey, Error> {
|
||||||
|
match kind {
|
||||||
|
SignerKind::Encryption => {
|
||||||
|
Ok(encryption.ok_or_else(|| anyhow!("Receiver's encryption key not found"))?)
|
||||||
}
|
}
|
||||||
|
SignerKind::User => Ok(member),
|
||||||
relay_urls.extend(urls.into_iter().take(3).unique());
|
SignerKind::Auto => Ok(encryption.unwrap_or(member)),
|
||||||
} else {
|
|
||||||
return Err(anyhow!("Not found"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(relay_urls)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
[package]
|
||||||
|
name = "chat_ui"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
state = { path = "../state" }
|
||||||
|
ui = { path = "../ui" }
|
||||||
|
theme = { path = "../theme" }
|
||||||
|
common = { path = "../common" }
|
||||||
|
account = { path = "../account" }
|
||||||
|
encryption = { path = "../encryption" }
|
||||||
|
person = { path = "../person" }
|
||||||
|
chat = { path = "../chat" }
|
||||||
|
settings = { path = "../settings" }
|
||||||
|
|
||||||
|
gpui.workspace = true
|
||||||
|
gpui_tokio.workspace = true
|
||||||
|
|
||||||
|
nostr-sdk.workspace = true
|
||||||
|
anyhow.workspace = true
|
||||||
|
itertools.workspace = true
|
||||||
|
smallvec.workspace = true
|
||||||
|
smol.workspace = true
|
||||||
|
log.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
|
||||||
|
indexset = "0.12.3"
|
||||||
|
emojis = "0.6.4"
|
||||||
|
once_cell = "1.19.0"
|
||||||
|
regex = "1"
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
use encryption::SignerKind;
|
||||||
|
use gpui::Action;
|
||||||
|
use nostr_sdk::prelude::*;
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
#[derive(Action, Clone, PartialEq, Eq, Deserialize)]
|
||||||
|
#[action(namespace = chat, no_json)]
|
||||||
|
pub struct SeenOn(pub EventId);
|
||||||
|
|
||||||
|
#[derive(Action, Clone, PartialEq, Eq, Deserialize)]
|
||||||
|
#[action(namespace = chat, no_json)]
|
||||||
|
pub struct SetSigner(pub SignerKind);
|
||||||
|
|
||||||
|
/// Define a open public key action
|
||||||
|
#[derive(Action, Clone, PartialEq, Eq, Deserialize, Debug)]
|
||||||
|
#[action(namespace = pubkey, no_json)]
|
||||||
|
pub struct OpenPublicKey(pub PublicKey);
|
||||||
|
|
||||||
|
/// Define a copy inline public key action
|
||||||
|
#[derive(Action, Clone, PartialEq, Eq, Deserialize, Debug)]
|
||||||
|
#[action(namespace = pubkey, no_json)]
|
||||||
|
pub struct CopyPublicKey(pub PublicKey);
|
||||||
@@ -6,11 +6,10 @@ use gpui::{
|
|||||||
RenderOnce, SharedString, StatefulInteractiveElement, Styled, WeakEntity, Window,
|
RenderOnce, SharedString, StatefulInteractiveElement, Styled, WeakEntity, Window,
|
||||||
};
|
};
|
||||||
use theme::ActiveTheme;
|
use theme::ActiveTheme;
|
||||||
|
use ui::button::{Button, ButtonVariants};
|
||||||
use crate::button::{Button, ButtonVariants};
|
use ui::input::InputState;
|
||||||
use crate::input::InputState;
|
use ui::popover::{Popover, PopoverContent};
|
||||||
use crate::popover::{Popover, PopoverContent};
|
use ui::{Icon, Sizable, Size};
|
||||||
use crate::{Icon, Sizable, Size};
|
|
||||||
|
|
||||||
static EMOJIS: OnceLock<Vec<SharedString>> = OnceLock::new();
|
static EMOJIS: OnceLock<Vec<SharedString>> = OnceLock::new();
|
||||||
|
|
||||||
@@ -31,27 +30,33 @@ fn get_emojis() -> &'static Vec<SharedString> {
|
|||||||
|
|
||||||
#[derive(IntoElement)]
|
#[derive(IntoElement)]
|
||||||
pub struct EmojiPicker {
|
pub struct EmojiPicker {
|
||||||
|
target: Option<WeakEntity<InputState>>,
|
||||||
icon: Option<Icon>,
|
icon: Option<Icon>,
|
||||||
size: Size,
|
|
||||||
anchor: Option<Corner>,
|
anchor: Option<Corner>,
|
||||||
target_input: WeakEntity<InputState>,
|
size: Size,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EmojiPicker {
|
impl EmojiPicker {
|
||||||
pub fn new(target_input: WeakEntity<InputState>) -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
target_input,
|
|
||||||
size: Size::default(),
|
size: Size::default(),
|
||||||
|
target: None,
|
||||||
anchor: None,
|
anchor: None,
|
||||||
icon: None,
|
icon: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn target(mut self, target: WeakEntity<InputState>) -> Self {
|
||||||
|
self.target = Some(target);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
|
pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
|
||||||
self.icon = Some(icon.into());
|
self.icon = Some(icon.into());
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn anchor(mut self, corner: Corner) -> Self {
|
pub fn anchor(mut self, corner: Corner) -> Self {
|
||||||
self.anchor = Some(corner);
|
self.anchor = Some(corner);
|
||||||
self
|
self
|
||||||
@@ -67,7 +72,7 @@ impl Sizable for EmojiPicker {
|
|||||||
|
|
||||||
impl RenderOnce for EmojiPicker {
|
impl RenderOnce for EmojiPicker {
|
||||||
fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
|
fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
|
||||||
Popover::new("emoji-picker")
|
Popover::new("emojis")
|
||||||
.map(|this| {
|
.map(|this| {
|
||||||
if let Some(corner) = self.anchor {
|
if let Some(corner) = self.anchor {
|
||||||
this.anchor(corner)
|
this.anchor(corner)
|
||||||
@@ -76,13 +81,13 @@ impl RenderOnce for EmojiPicker {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.trigger(
|
.trigger(
|
||||||
Button::new("emoji-trigger")
|
Button::new("emojis-trigger")
|
||||||
.when_some(self.icon, |this, icon| this.icon(icon))
|
.when_some(self.icon, |this, icon| this.icon(icon))
|
||||||
.ghost()
|
.ghost()
|
||||||
.with_size(self.size),
|
.with_size(self.size),
|
||||||
)
|
)
|
||||||
.content(move |window, cx| {
|
.content(move |window, cx| {
|
||||||
let input = self.target_input.clone();
|
let input = self.target.clone();
|
||||||
|
|
||||||
cx.new(|cx| {
|
cx.new(|cx| {
|
||||||
PopoverContent::new(window, cx, move |_window, cx| {
|
PopoverContent::new(window, cx, move |_window, cx| {
|
||||||
@@ -104,18 +109,18 @@ impl RenderOnce for EmojiPicker {
|
|||||||
.hover(|this| this.bg(cx.theme().ghost_element_hover))
|
.hover(|this| this.bg(cx.theme().ghost_element_hover))
|
||||||
.on_click({
|
.on_click({
|
||||||
let item = e.clone();
|
let item = e.clone();
|
||||||
let input = input.upgrade();
|
let input = input.clone();
|
||||||
|
|
||||||
move |_, window, cx| {
|
move |_, window, cx| {
|
||||||
if let Some(input) = input.as_ref() {
|
if let Some(input) = input.as_ref() {
|
||||||
input.update(cx, |this, cx| {
|
_ = input.update(cx, |this, cx| {
|
||||||
let current = this.value();
|
let value = this.value();
|
||||||
let new_text = if current.is_empty() {
|
let new_text = if value.is_empty() {
|
||||||
format!("{item}")
|
format!("{item}")
|
||||||
} else if current.ends_with(" ") {
|
} else if value.ends_with(" ") {
|
||||||
format!("{current}{item}")
|
format!("{value}{item}")
|
||||||
} else {
|
} else {
|
||||||
format!("{current} {item}")
|
format!("{value} {item}")
|
||||||
};
|
};
|
||||||
this.set_value(new_text, window, cx);
|
this.set_value(new_text, window, cx);
|
||||||
});
|
});
|
||||||
@@ -1,60 +1,56 @@
|
|||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::HashSet;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use common::display::{RenderedProfile, RenderedTimestamp};
|
pub use actions::*;
|
||||||
use common::nip96::nip96_upload;
|
use chat::{Message, RenderedMessage, Room, RoomKind, RoomSignal, SendOptions, SendReport};
|
||||||
use global::{app_state, nostr_client};
|
use common::{nip96_upload, RenderedProfile, RenderedTimestamp};
|
||||||
|
use encryption::SignerKind;
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
div, img, list, px, red, relative, rems, svg, white, Action, AnyElement, App, AppContext,
|
div, img, list, px, red, relative, rems, svg, white, AnyElement, App, AppContext,
|
||||||
ClipboardItem, Context, Element, Entity, EventEmitter, Flatten, FocusHandle, Focusable,
|
ClipboardItem, Context, Element, Entity, EventEmitter, Flatten, FocusHandle, Focusable,
|
||||||
InteractiveElement, IntoElement, ListAlignment, ListOffset, ListState, MouseButton, ObjectFit,
|
InteractiveElement, IntoElement, ListAlignment, ListOffset, ListState, MouseButton, ObjectFit,
|
||||||
ParentElement, PathPromptOptions, Render, RetainAllImageCache, SharedString, SharedUri,
|
ParentElement, PathPromptOptions, Render, RetainAllImageCache, SharedString,
|
||||||
StatefulInteractiveElement, Styled, StyledImage, Subscription, Task, Window,
|
StatefulInteractiveElement, Styled, StyledImage, Subscription, Task, Window,
|
||||||
};
|
};
|
||||||
use gpui_tokio::Tokio;
|
use gpui_tokio::Tokio;
|
||||||
use i18n::{shared_t, t};
|
|
||||||
use indexset::{BTreeMap, BTreeSet};
|
use indexset::{BTreeMap, BTreeSet};
|
||||||
use itertools::Itertools;
|
use itertools::Itertools;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use registry::message::{Message, RenderedMessage};
|
use person::PersonRegistry;
|
||||||
use registry::room::{Room, RoomKind, RoomSignal, SendReport};
|
|
||||||
use registry::Registry;
|
|
||||||
use serde::Deserialize;
|
|
||||||
use settings::AppSettings;
|
use settings::AppSettings;
|
||||||
use smallvec::{smallvec, SmallVec};
|
use smallvec::{smallvec, SmallVec};
|
||||||
use smol::fs;
|
use smol::fs;
|
||||||
|
use state::NostrRegistry;
|
||||||
use theme::ActiveTheme;
|
use theme::ActiveTheme;
|
||||||
use ui::actions::{CopyPublicKey, OpenPublicKey};
|
|
||||||
use ui::avatar::Avatar;
|
use ui::avatar::Avatar;
|
||||||
use ui::button::{Button, ButtonVariants};
|
use ui::button::{Button, ButtonVariants};
|
||||||
use ui::context_menu::ContextMenuExt;
|
use ui::context_menu::ContextMenuExt;
|
||||||
use ui::dock_area::panel::{Panel, PanelEvent};
|
use ui::dock_area::panel::{Panel, PanelEvent};
|
||||||
use ui::emoji_picker::EmojiPicker;
|
|
||||||
use ui::input::{InputEvent, InputState, TextInput};
|
use ui::input::{InputEvent, InputState, TextInput};
|
||||||
use ui::modal::ModalButtonProps;
|
use ui::modal::ModalButtonProps;
|
||||||
use ui::notification::Notification;
|
use ui::notification::Notification;
|
||||||
use ui::popup_menu::{PopupMenu, PopupMenuExt};
|
use ui::popup_menu::PopupMenuExt;
|
||||||
use ui::text::RenderedText;
|
|
||||||
use ui::{
|
use ui::{
|
||||||
h_flex, v_flex, ContextModal, Disableable, Icon, IconName, InteractiveElementExt, Sizable,
|
h_flex, v_flex, ContextModal, Disableable, Icon, IconName, InteractiveElementExt, Sizable,
|
||||||
StyledExt,
|
StyledExt,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use crate::emoji::EmojiPicker;
|
||||||
|
use crate::text::RenderedText;
|
||||||
|
|
||||||
|
mod actions;
|
||||||
|
mod emoji;
|
||||||
mod subject;
|
mod subject;
|
||||||
|
mod text;
|
||||||
|
|
||||||
#[derive(Action, Clone, PartialEq, Eq, Deserialize)]
|
pub fn init(room: Entity<Room>, window: &mut Window, cx: &mut App) -> Entity<ChatPanel> {
|
||||||
#[action(namespace = chat, no_json)]
|
cx.new(|cx| ChatPanel::new(room, window, cx))
|
||||||
pub struct SeenOn(pub EventId);
|
|
||||||
|
|
||||||
pub fn init(room: Entity<Room>, window: &mut Window, cx: &mut App) -> Entity<Chat> {
|
|
||||||
cx.new(|cx| Chat::new(room, window, cx))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct Chat {
|
pub struct ChatPanel {
|
||||||
// Chat Room
|
// Chat Room
|
||||||
room: Entity<Room>,
|
room: Entity<Room>,
|
||||||
relays: Entity<HashMap<PublicKey, Vec<RelayUrl>>>,
|
|
||||||
|
|
||||||
// Messages
|
// Messages
|
||||||
list_state: ListState,
|
list_state: ListState,
|
||||||
@@ -64,6 +60,7 @@ pub struct Chat {
|
|||||||
|
|
||||||
// New Message
|
// New Message
|
||||||
input: Entity<InputState>,
|
input: Entity<InputState>,
|
||||||
|
options: Entity<SendOptions>,
|
||||||
replies_to: Entity<HashSet<EventId>>,
|
replies_to: Entity<HashSet<EventId>>,
|
||||||
|
|
||||||
// Media Attachment
|
// Media Attachment
|
||||||
@@ -75,41 +72,47 @@ pub struct Chat {
|
|||||||
focus_handle: FocusHandle,
|
focus_handle: FocusHandle,
|
||||||
image_cache: Entity<RetainAllImageCache>,
|
image_cache: Entity<RetainAllImageCache>,
|
||||||
|
|
||||||
_subscriptions: SmallVec<[Subscription; 4]>,
|
_subscriptions: SmallVec<[Subscription; 3]>,
|
||||||
_tasks: SmallVec<[Task<()>; 2]>,
|
_tasks: SmallVec<[Task<()>; 2]>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Chat {
|
impl ChatPanel {
|
||||||
pub fn new(room: Entity<Room>, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
pub fn new(room: Entity<Room>, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||||
let attachments = cx.new(|_| vec![]);
|
|
||||||
let replies_to = cx.new(|_| HashSet::new());
|
|
||||||
|
|
||||||
let relays = cx.new(|_| {
|
|
||||||
let this: HashMap<PublicKey, Vec<RelayUrl>> = HashMap::new();
|
|
||||||
this
|
|
||||||
});
|
|
||||||
|
|
||||||
let input = cx.new(|cx| {
|
let input = cx.new(|cx| {
|
||||||
InputState::new(window, cx)
|
InputState::new(window, cx)
|
||||||
.placeholder(t!("chat.placeholder"))
|
.placeholder("Message...")
|
||||||
.auto_grow(1, 20)
|
.auto_grow(1, 20)
|
||||||
.prevent_new_line_on_enter()
|
.prevent_new_line_on_enter()
|
||||||
.clean_on_escape()
|
.clean_on_escape()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let attachments = cx.new(|_| vec![]);
|
||||||
|
let replies_to = cx.new(|_| HashSet::new());
|
||||||
|
let options = cx.new(|_| SendOptions::default());
|
||||||
|
|
||||||
|
let id = room.read(cx).id.to_string().into();
|
||||||
let messages = BTreeSet::from([Message::system()]);
|
let messages = BTreeSet::from([Message::system()]);
|
||||||
let list_state = ListState::new(messages.len(), ListAlignment::Bottom, px(1024.));
|
let list_state = ListState::new(messages.len(), ListAlignment::Bottom, px(1024.));
|
||||||
|
|
||||||
let connect = room.read(cx).connect(cx);
|
let connect = room.read(cx).connect(cx);
|
||||||
let load_messages = room.read(cx).load_messages(cx);
|
let get_messages = room.read(cx).get_messages(cx);
|
||||||
|
|
||||||
let mut subscriptions = smallvec![];
|
let mut subscriptions = smallvec![];
|
||||||
let mut tasks = smallvec![];
|
let mut tasks = smallvec![];
|
||||||
|
|
||||||
|
tasks.push(
|
||||||
|
// Get messaging relays and encryption keys announcement for each member
|
||||||
|
cx.background_spawn(async move {
|
||||||
|
if let Err(e) = connect.await {
|
||||||
|
log::error!("Failed to initialize room: {}", e);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
tasks.push(
|
tasks.push(
|
||||||
// Load all messages belonging to this room
|
// Load all messages belonging to this room
|
||||||
cx.spawn_in(window, async move |this, cx| {
|
cx.spawn_in(window, async move |this, cx| {
|
||||||
let result = load_messages.await;
|
let result = get_messages.await;
|
||||||
|
|
||||||
this.update_in(cx, |this, window, cx| {
|
this.update_in(cx, |this, window, cx| {
|
||||||
match result {
|
match result {
|
||||||
@@ -125,28 +128,6 @@ impl Chat {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
tasks.push(
|
|
||||||
// Get messaging relays for all members
|
|
||||||
cx.spawn_in(window, async move |this, cx| {
|
|
||||||
let result = connect.await;
|
|
||||||
|
|
||||||
this.update_in(cx, |this, _window, cx| {
|
|
||||||
match result {
|
|
||||||
Ok(relays) => {
|
|
||||||
this.relays.update(cx, |this, cx| {
|
|
||||||
this.extend(relays);
|
|
||||||
cx.notify();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
this.insert_warning(e.to_string(), cx);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
subscriptions.push(
|
subscriptions.push(
|
||||||
// Subscribe to input events
|
// Subscribe to input events
|
||||||
cx.subscribe_in(
|
cx.subscribe_in(
|
||||||
@@ -165,15 +146,16 @@ impl Chat {
|
|||||||
cx.subscribe_in(&room, window, move |this, _, signal, window, cx| {
|
cx.subscribe_in(&room, window, move |this, _, signal, window, cx| {
|
||||||
match signal {
|
match signal {
|
||||||
RoomSignal::NewMessage((gift_wrap_id, event)) => {
|
RoomSignal::NewMessage((gift_wrap_id, event)) => {
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let tracker = nostr.read(cx).tracker();
|
||||||
let gift_wrap_id = gift_wrap_id.to_owned();
|
let gift_wrap_id = gift_wrap_id.to_owned();
|
||||||
let message = Message::user(event);
|
let message = Message::user(event.clone());
|
||||||
|
|
||||||
cx.spawn_in(window, async move |this, cx| {
|
cx.spawn_in(window, async move |this, cx| {
|
||||||
let app_state = app_state();
|
let tracker = tracker.read().await;
|
||||||
let sent_ids = app_state.sent_ids.read().await;
|
|
||||||
|
|
||||||
this.update_in(cx, |this, _window, cx| {
|
this.update_in(cx, |this, _window, cx| {
|
||||||
if !sent_ids.contains(&gift_wrap_id) {
|
if !tracker.sent_ids().contains(&gift_wrap_id) {
|
||||||
this.insert_message(message, false, cx);
|
this.insert_message(message, false, cx);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -188,23 +170,6 @@ impl Chat {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
subscriptions.push(
|
|
||||||
// Observe the messaging relays of the room's members
|
|
||||||
cx.observe_in(&relays, window, |this, entity, _window, cx| {
|
|
||||||
let registry = Registry::global(cx);
|
|
||||||
let relays = entity.read(cx).clone();
|
|
||||||
|
|
||||||
for (public_key, urls) in relays.iter() {
|
|
||||||
if urls.is_empty() {
|
|
||||||
let profile = registry.read(cx).get_person(public_key, cx);
|
|
||||||
let content = t!("chat.nip17_not_found", u = profile.name());
|
|
||||||
|
|
||||||
this.insert_warning(content, cx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
subscriptions.push(
|
subscriptions.push(
|
||||||
// Observe when user close chat panel
|
// Observe when user close chat panel
|
||||||
cx.on_release_in(window, move |this, window, cx| {
|
cx.on_release_in(window, move |this, window, cx| {
|
||||||
@@ -218,19 +183,19 @@ impl Chat {
|
|||||||
);
|
);
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
id: room.read(cx).id.to_string().into(),
|
id,
|
||||||
image_cache: RetainAllImageCache::new(cx),
|
|
||||||
focus_handle: cx.focus_handle(),
|
|
||||||
rendered_texts_by_id: BTreeMap::new(),
|
|
||||||
reports_by_id: BTreeMap::new(),
|
|
||||||
relays,
|
|
||||||
messages,
|
messages,
|
||||||
room,
|
room,
|
||||||
list_state,
|
list_state,
|
||||||
input,
|
input,
|
||||||
replies_to,
|
replies_to,
|
||||||
attachments,
|
attachments,
|
||||||
|
options,
|
||||||
|
rendered_texts_by_id: BTreeMap::new(),
|
||||||
|
reports_by_id: BTreeMap::new(),
|
||||||
uploading: false,
|
uploading: false,
|
||||||
|
image_cache: RetainAllImageCache::new(cx),
|
||||||
|
focus_handle: cx.focus_handle(),
|
||||||
_subscriptions: subscriptions,
|
_subscriptions: subscriptions,
|
||||||
_tasks: tasks,
|
_tasks: tasks,
|
||||||
}
|
}
|
||||||
@@ -238,12 +203,12 @@ impl Chat {
|
|||||||
|
|
||||||
/// Load all messages belonging to this room
|
/// Load all messages belonging to this room
|
||||||
fn load_messages(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
fn load_messages(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
let load_messages = self.room.read(cx).load_messages(cx);
|
let get_messages = self.room.read(cx).get_messages(cx);
|
||||||
|
|
||||||
self._tasks.push(
|
self._tasks.push(
|
||||||
// Run the task in the background
|
// Run the task in the background
|
||||||
cx.spawn_in(window, async move |this, cx| {
|
cx.spawn_in(window, async move |this, cx| {
|
||||||
let result = load_messages.await;
|
let result = get_messages.await;
|
||||||
|
|
||||||
this.update_in(cx, |this, window, cx| {
|
this.update_in(cx, |this, window, cx| {
|
||||||
match result {
|
match result {
|
||||||
@@ -291,7 +256,7 @@ impl Chat {
|
|||||||
|
|
||||||
// Return if message is empty
|
// Return if message is empty
|
||||||
if content.trim().is_empty() {
|
if content.trim().is_empty() {
|
||||||
window.push_notification(t!("chat.empty_message_error"), cx);
|
window.push_notification("Cannot send an empty message", cx);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,28 +267,28 @@ impl Chat {
|
|||||||
this.set_value("", window, cx);
|
this.set_value("", window, cx);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get the backup setting
|
|
||||||
let backup = AppSettings::get_backup_messages(cx);
|
|
||||||
|
|
||||||
// Get replies_to if it's present
|
// Get replies_to if it's present
|
||||||
let replies: Vec<EventId> = self.replies_to.read(cx).iter().copied().collect();
|
let replies: Vec<EventId> = self.replies_to.read(cx).iter().copied().collect();
|
||||||
|
|
||||||
// Get the current room entity
|
// Get the current room entity
|
||||||
let room = self.room.read(cx);
|
let room = self.room.read(cx);
|
||||||
|
let opts = self.options.read(cx);
|
||||||
|
|
||||||
// Create a temporary message for optimistic update
|
// Create a temporary message for optimistic update
|
||||||
let rumor = room.create_message(&content, replies.as_ref(), cx);
|
let rumor = room.create_message(&content, replies.as_ref(), cx);
|
||||||
let rumor_id = rumor.id.unwrap();
|
let rumor_id = rumor.id.unwrap();
|
||||||
|
|
||||||
// Create a task for sending the message in the background
|
// Create a task for sending the message in the background
|
||||||
let send_message = room.send_message(rumor.clone(), backup, cx);
|
let send_message = room.send_message(&rumor, opts, cx);
|
||||||
|
|
||||||
// Optimistically update message list
|
// Optimistically update message list
|
||||||
cx.spawn_in(window, async move |this, cx| {
|
cx.spawn_in(window, async move |this, cx| {
|
||||||
|
// Wait for the delay
|
||||||
cx.background_executor()
|
cx.background_executor()
|
||||||
.timer(Duration::from_millis(100))
|
.timer(Duration::from_millis(100))
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
// Update the message list and reset the states
|
||||||
this.update_in(cx, |this, window, cx| {
|
this.update_in(cx, |this, window, cx| {
|
||||||
this.insert_message(Message::user(rumor), true, cx);
|
this.insert_message(Message::user(rumor), true, cx);
|
||||||
this.remove_all_replies(cx);
|
this.remove_all_replies(cx);
|
||||||
@@ -338,6 +303,7 @@ impl Chat {
|
|||||||
})
|
})
|
||||||
.detach();
|
.detach();
|
||||||
|
|
||||||
|
self._tasks.push(
|
||||||
// Continue sending the message in the background
|
// Continue sending the message in the background
|
||||||
cx.spawn_in(window, async move |this, cx| {
|
cx.spawn_in(window, async move |this, cx| {
|
||||||
let result = send_message.await;
|
let result = send_message.await;
|
||||||
@@ -345,10 +311,11 @@ impl Chat {
|
|||||||
this.update_in(cx, |this, window, cx| {
|
this.update_in(cx, |this, window, cx| {
|
||||||
match result {
|
match result {
|
||||||
Ok(reports) => {
|
Ok(reports) => {
|
||||||
|
// Update room's status
|
||||||
this.room.update(cx, |this, cx| {
|
this.room.update(cx, |this, cx| {
|
||||||
if this.kind != RoomKind::Ongoing {
|
if this.kind != RoomKind::Ongoing {
|
||||||
// Update the room kind to ongoing
|
// Update the room kind to ongoing,
|
||||||
// But keep the room kind if send failed
|
// but keep the room kind if send failed
|
||||||
if reports.iter().all(|r| !r.is_sent_success()) {
|
if reports.iter().all(|r| !r.is_sent_success()) {
|
||||||
this.kind = RoomKind::Ongoing;
|
this.kind = RoomKind::Ongoing;
|
||||||
cx.notify();
|
cx.notify();
|
||||||
@@ -367,11 +334,12 @@ impl Chat {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.ok();
|
.ok();
|
||||||
})
|
}),
|
||||||
.detach();
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resend a failed message
|
/// Resend a failed message
|
||||||
|
#[allow(dead_code)]
|
||||||
fn resend_message(&mut self, id: &EventId, window: &mut Window, cx: &mut Context<Self>) {
|
fn resend_message(&mut self, id: &EventId, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
if let Some(reports) = self.reports_by_id.get(id).cloned() {
|
if let Some(reports) = self.reports_by_id.get(id).cloned() {
|
||||||
let id_clone = id.to_owned();
|
let id_clone = id.to_owned();
|
||||||
@@ -422,8 +390,8 @@ impl Chat {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Convert and insert a vector of nostr events into the chat panel
|
/// Convert and insert a vector of nostr events into the chat panel
|
||||||
fn insert_messages(&mut self, events: Vec<Event>, cx: &mut Context<Self>) {
|
fn insert_messages(&mut self, events: Vec<UnsignedEvent>, cx: &mut Context<Self>) {
|
||||||
for event in events.into_iter() {
|
for event in events {
|
||||||
let m = Message::user(event);
|
let m = Message::user(event);
|
||||||
// Bulk inserting messages, so no need to scroll to the latest message
|
// Bulk inserting messages, so no need to scroll to the latest message
|
||||||
self.insert_message(m, false, cx);
|
self.insert_message(m, false, cx);
|
||||||
@@ -431,6 +399,7 @@ impl Chat {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Insert a warning message into the chat panel
|
/// Insert a warning message into the chat panel
|
||||||
|
#[allow(dead_code)]
|
||||||
fn insert_warning(&mut self, content: impl Into<String>, cx: &mut Context<Self>) {
|
fn insert_warning(&mut self, content: impl Into<String>, cx: &mut Context<Self>) {
|
||||||
let m = Message::warning(content.into());
|
let m = Message::warning(content.into());
|
||||||
self.insert_message(m, true, cx);
|
self.insert_message(m, true, cx);
|
||||||
@@ -468,8 +437,12 @@ impl Chat {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn profile(&self, public_key: &PublicKey, cx: &Context<Self>) -> Profile {
|
fn profile(&self, public_key: &PublicKey, cx: &Context<Self>) -> Profile {
|
||||||
let registry = Registry::read_global(cx);
|
let persons = PersonRegistry::global(cx);
|
||||||
registry.get_person(public_key, cx)
|
persons.read(cx).get_person(public_key, cx)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn signer_kind(&self, cx: &App) -> SignerKind {
|
||||||
|
self.options.read(cx).signer_kind
|
||||||
}
|
}
|
||||||
|
|
||||||
fn scroll_to(&self, id: EventId) {
|
fn scroll_to(&self, id: EventId) {
|
||||||
@@ -514,6 +487,9 @@ impl Chat {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn upload(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
fn upload(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let client = nostr.read(cx).client();
|
||||||
|
|
||||||
// Get the user's configured NIP96 server
|
// Get the user's configured NIP96 server
|
||||||
let nip96_server = AppSettings::get_media_server(cx);
|
let nip96_server = AppSettings::get_media_server(cx);
|
||||||
|
|
||||||
@@ -529,9 +505,8 @@ impl Chat {
|
|||||||
let path = paths.pop()?;
|
let path = paths.pop()?;
|
||||||
|
|
||||||
let upload = Tokio::spawn(cx, async move {
|
let upload = Tokio::spawn(cx, async move {
|
||||||
let client = nostr_client();
|
|
||||||
let file = fs::read(path).await.ok()?;
|
let file = fs::read(path).await.ok()?;
|
||||||
let url = nip96_upload(client, &nip96_server, file).await.ok()?;
|
let url = nip96_upload(&client, &nip96_server, file).await.ok()?;
|
||||||
|
|
||||||
Some(url)
|
Some(url)
|
||||||
});
|
});
|
||||||
@@ -542,30 +517,25 @@ impl Chat {
|
|||||||
})
|
})
|
||||||
.ok();
|
.ok();
|
||||||
|
|
||||||
match Flatten::flatten(task.await.map_err(|e| e.into())) {
|
let result = Flatten::flatten(task.await.map_err(|e| e.into()));
|
||||||
|
|
||||||
|
this.update_in(cx, |this, window, cx| {
|
||||||
|
match result {
|
||||||
Ok(Some(url)) => {
|
Ok(Some(url)) => {
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
this.add_attachment(url, cx);
|
this.add_attachment(url, cx);
|
||||||
this.set_uploading(false, cx);
|
this.set_uploading(false, cx);
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
}
|
}
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
this.update_in(cx, |this, window, cx| {
|
|
||||||
window.push_notification("Failed to upload file", cx);
|
|
||||||
this.set_uploading(false, cx);
|
this.set_uploading(false, cx);
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
this.update_in(cx, |this, window, cx| {
|
|
||||||
window.push_notification(Notification::error(e.to_string()), cx);
|
window.push_notification(Notification::error(e.to_string()), cx);
|
||||||
this.set_uploading(false, cx);
|
this.set_uploading(false, cx);
|
||||||
|
}
|
||||||
|
};
|
||||||
})
|
})
|
||||||
.ok();
|
.ok();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Some(())
|
Some(())
|
||||||
})
|
})
|
||||||
@@ -622,7 +592,9 @@ impl Chat {
|
|||||||
.size_10()
|
.size_10()
|
||||||
.text_color(cx.theme().elevated_surface_background),
|
.text_color(cx.theme().elevated_surface_background),
|
||||||
)
|
)
|
||||||
.child(shared_t!("chat.notice"))
|
.child(SharedString::from(
|
||||||
|
"This conversation is private. Only members can see each other's messages.",
|
||||||
|
))
|
||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -655,6 +627,33 @@ impl Chat {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn render_message(
|
fn render_message(
|
||||||
|
&mut self,
|
||||||
|
ix: usize,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) -> AnyElement {
|
||||||
|
if let Some(message) = self.messages.get_index(ix) {
|
||||||
|
match message {
|
||||||
|
Message::User(rendered) => {
|
||||||
|
let text = self
|
||||||
|
.rendered_texts_by_id
|
||||||
|
.entry(rendered.id)
|
||||||
|
.or_insert_with(|| RenderedText::new(&rendered.content, cx))
|
||||||
|
.element(ix.into(), window, cx);
|
||||||
|
|
||||||
|
self.render_text_message(ix, rendered, text, cx)
|
||||||
|
}
|
||||||
|
Message::Warning(content, _timestamp) => {
|
||||||
|
self.render_warning(ix, SharedString::from(content), cx)
|
||||||
|
}
|
||||||
|
Message::System(_timestamp) => self.render_announcement(ix, cx),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.render_warning(ix, SharedString::from("Message not found"), cx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_text_message(
|
||||||
&self,
|
&self,
|
||||||
ix: usize,
|
ix: usize,
|
||||||
message: &RenderedMessage,
|
message: &RenderedMessage,
|
||||||
@@ -697,8 +696,8 @@ impl Chat {
|
|||||||
let view = Box::new(OpenPublicKey(public_key));
|
let view = Box::new(OpenPublicKey(public_key));
|
||||||
let copy = Box::new(CopyPublicKey(public_key));
|
let copy = Box::new(CopyPublicKey(public_key));
|
||||||
|
|
||||||
this.menu(t!("profile.view"), view)
|
this.menu("View Profile", view)
|
||||||
.menu(t!("profile.copy"), copy)
|
.menu("Copy Public Key", copy)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -731,23 +730,7 @@ impl Chat {
|
|||||||
})
|
})
|
||||||
.child(text)
|
.child(text)
|
||||||
.when(is_sent_failed, |this| {
|
.when(is_sent_failed, |this| {
|
||||||
this.child(
|
this.child(self.render_message_reports(&id, cx))
|
||||||
h_flex()
|
|
||||||
.gap_1()
|
|
||||||
.child(self.render_message_reports(&id, cx))
|
|
||||||
.child(
|
|
||||||
Button::new(SharedString::from(id.to_hex()))
|
|
||||||
.label(t!("common.resend"))
|
|
||||||
.danger()
|
|
||||||
.xsmall()
|
|
||||||
.rounded()
|
|
||||||
.on_click(cx.listener(
|
|
||||||
move |this, _, window, cx| {
|
|
||||||
this.resend_message(&id, window, cx);
|
|
||||||
},
|
|
||||||
)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -815,14 +798,14 @@ impl Chat {
|
|||||||
fn render_message_sent(&self, id: &EventId, _cx: &Context<Self>) -> impl IntoElement {
|
fn render_message_sent(&self, id: &EventId, _cx: &Context<Self>) -> impl IntoElement {
|
||||||
div()
|
div()
|
||||||
.id(SharedString::from(id.to_hex()))
|
.id(SharedString::from(id.to_hex()))
|
||||||
.child(shared_t!("chat.sent"))
|
.child(SharedString::from("• Sent"))
|
||||||
.when_some(self.sent_reports(id).cloned(), |this, reports| {
|
.when_some(self.sent_reports(id).cloned(), |this, reports| {
|
||||||
this.on_click(move |_e, window, cx| {
|
this.on_click(move |_e, window, cx| {
|
||||||
let reports = reports.clone();
|
let reports = reports.clone();
|
||||||
|
|
||||||
window.open_modal(cx, move |this, _window, cx| {
|
window.open_modal(cx, move |this, _window, cx| {
|
||||||
this.show_close(true)
|
this.show_close(true)
|
||||||
.title(shared_t!("chat.reports"))
|
.title(SharedString::from("Sent Reports"))
|
||||||
.child(v_flex().pb_4().gap_4().children({
|
.child(v_flex().pb_4().gap_4().children({
|
||||||
let mut items = Vec::with_capacity(reports.len());
|
let mut items = Vec::with_capacity(reports.len());
|
||||||
|
|
||||||
@@ -845,14 +828,16 @@ impl Chat {
|
|||||||
.text_xs()
|
.text_xs()
|
||||||
.italic()
|
.italic()
|
||||||
.child(Icon::new(IconName::Info).xsmall())
|
.child(Icon::new(IconName::Info).xsmall())
|
||||||
.child(shared_t!("chat.sent_failed"))
|
.child(SharedString::from(
|
||||||
|
"Failed to send message. Click to see details.",
|
||||||
|
))
|
||||||
.when_some(self.sent_reports(id).cloned(), |this, reports| {
|
.when_some(self.sent_reports(id).cloned(), |this, reports| {
|
||||||
this.on_click(move |_e, window, cx| {
|
this.on_click(move |_e, window, cx| {
|
||||||
let reports = reports.clone();
|
let reports = reports.clone();
|
||||||
|
|
||||||
window.open_modal(cx, move |this, _window, cx| {
|
window.open_modal(cx, move |this, _window, cx| {
|
||||||
this.show_close(true)
|
this.show_close(true)
|
||||||
.title(shared_t!("chat.reports"))
|
.title(SharedString::from("Sent Reports"))
|
||||||
.child(v_flex().gap_4().pb_4().w_full().children({
|
.child(v_flex().gap_4().pb_4().w_full().children({
|
||||||
let mut items = Vec::with_capacity(reports.len());
|
let mut items = Vec::with_capacity(reports.len());
|
||||||
|
|
||||||
@@ -868,8 +853,8 @@ impl Chat {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn render_report(report: &SendReport, cx: &App) -> impl IntoElement {
|
fn render_report(report: &SendReport, cx: &App) -> impl IntoElement {
|
||||||
let registry = Registry::read_global(cx);
|
let persons = PersonRegistry::global(cx);
|
||||||
let profile = registry.get_person(&report.receiver, cx);
|
let profile = persons.read(cx).get_person(&report.receiver, cx);
|
||||||
let name = profile.display_name();
|
let name = profile.display_name();
|
||||||
let avatar = profile.avatar(true);
|
let avatar = profile.avatar(true);
|
||||||
|
|
||||||
@@ -880,7 +865,7 @@ impl Chat {
|
|||||||
h_flex()
|
h_flex()
|
||||||
.gap_2()
|
.gap_2()
|
||||||
.text_sm()
|
.text_sm()
|
||||||
.child(shared_t!("chat.sent_to"))
|
.child(SharedString::from("Sent to:"))
|
||||||
.child(
|
.child(
|
||||||
h_flex()
|
h_flex()
|
||||||
.gap_1()
|
.gap_1()
|
||||||
@@ -906,7 +891,28 @@ impl Chat {
|
|||||||
.flex_1()
|
.flex_1()
|
||||||
.w_full()
|
.w_full()
|
||||||
.text_center()
|
.text_center()
|
||||||
.child(shared_t!("chat.nip17_not_found", u = name)),
|
.child(SharedString::from("Messaging Relays not found")),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.when(report.device_not_found, |this| {
|
||||||
|
this.child(
|
||||||
|
h_flex()
|
||||||
|
.flex_wrap()
|
||||||
|
.justify_center()
|
||||||
|
.p_2()
|
||||||
|
.h_20()
|
||||||
|
.w_full()
|
||||||
|
.text_sm()
|
||||||
|
.rounded(cx.theme().radius)
|
||||||
|
.bg(cx.theme().danger_background)
|
||||||
|
.text_color(cx.theme().danger_foreground)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex_1()
|
||||||
|
.w_full()
|
||||||
|
.text_center()
|
||||||
|
.child(SharedString::from("Encryption Key not found")),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -985,7 +991,7 @@ impl Chat {
|
|||||||
.text_sm()
|
.text_sm()
|
||||||
.text_color(cx.theme().secondary_foreground)
|
.text_color(cx.theme().secondary_foreground)
|
||||||
.line_height(relative(1.25))
|
.line_height(relative(1.25))
|
||||||
.child(shared_t!("chat.sent_success")),
|
.child(SharedString::from("Successfully")),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1023,7 +1029,7 @@ impl Chat {
|
|||||||
.child(
|
.child(
|
||||||
Button::new("reply")
|
Button::new("reply")
|
||||||
.icon(IconName::Reply)
|
.icon(IconName::Reply)
|
||||||
.tooltip(t!("chat.reply_button"))
|
.tooltip("Reply")
|
||||||
.small()
|
.small()
|
||||||
.ghost()
|
.ghost()
|
||||||
.on_click({
|
.on_click({
|
||||||
@@ -1036,7 +1042,7 @@ impl Chat {
|
|||||||
.child(
|
.child(
|
||||||
Button::new("copy")
|
Button::new("copy")
|
||||||
.icon(IconName::Copy)
|
.icon(IconName::Copy)
|
||||||
.tooltip(t!("chat.copy_message_button"))
|
.tooltip("Copy")
|
||||||
.small()
|
.small()
|
||||||
.ghost()
|
.ghost()
|
||||||
.on_click({
|
.on_click({
|
||||||
@@ -1054,9 +1060,7 @@ impl Chat {
|
|||||||
.ghost()
|
.ghost()
|
||||||
.popup_menu({
|
.popup_menu({
|
||||||
let id = id.to_owned();
|
let id = id.to_owned();
|
||||||
move |this, _window, _cx| {
|
move |this, _, _| this.menu("Seen on", Box::new(SeenOn(id)))
|
||||||
this.menu(t!("common.seen_on"), Box::new(SeenOn(id)))
|
|
||||||
}
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.group_hover("", |this| this.visible())
|
.group_hover("", |this| this.visible())
|
||||||
@@ -1068,7 +1072,7 @@ impl Chat {
|
|||||||
.relative()
|
.relative()
|
||||||
.w_16()
|
.w_16()
|
||||||
.child(
|
.child(
|
||||||
img(SharedUri::from(url.to_string()))
|
img(url.as_str())
|
||||||
.size_16()
|
.size_16()
|
||||||
.shadow_lg()
|
.shadow_lg()
|
||||||
.rounded(cx.theme().radius)
|
.rounded(cx.theme().radius)
|
||||||
@@ -1111,8 +1115,8 @@ impl Chat {
|
|||||||
|
|
||||||
fn render_reply(&self, id: &EventId, cx: &Context<Self>) -> impl IntoElement {
|
fn render_reply(&self, id: &EventId, cx: &Context<Self>) -> impl IntoElement {
|
||||||
if let Some(text) = self.message(id) {
|
if let Some(text) = self.message(id) {
|
||||||
let registry = Registry::read_global(cx);
|
let persons = PersonRegistry::global(cx);
|
||||||
let profile = registry.get_person(&text.author, cx);
|
let profile = persons.read(cx).get_person(&text.author, cx);
|
||||||
|
|
||||||
div()
|
div()
|
||||||
.w_full()
|
.w_full()
|
||||||
@@ -1131,7 +1135,7 @@ impl Chat {
|
|||||||
.gap_1()
|
.gap_1()
|
||||||
.text_xs()
|
.text_xs()
|
||||||
.text_color(cx.theme().text_muted)
|
.text_color(cx.theme().text_muted)
|
||||||
.child(SharedString::new(t!("chat.replying_to_label")))
|
.child(SharedString::from("Replying to:"))
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
.text_color(cx.theme().text_accent)
|
.text_color(cx.theme().text_accent)
|
||||||
@@ -1189,7 +1193,7 @@ impl Chat {
|
|||||||
|
|
||||||
Button::new("subject")
|
Button::new("subject")
|
||||||
.icon(IconName::Edit)
|
.icon(IconName::Edit)
|
||||||
.tooltip(t!("chat.subject_tooltip"))
|
.tooltip("Change the subject of the conversation")
|
||||||
.on_click(move |_, window, cx| {
|
.on_click(move |_, window, cx| {
|
||||||
let view = subject::init(subject.clone(), window, cx);
|
let view = subject::init(subject.clone(), window, cx);
|
||||||
let room = room.clone();
|
let room = room.clone();
|
||||||
@@ -1200,9 +1204,9 @@ impl Chat {
|
|||||||
let weak_view = weak_view.clone();
|
let weak_view = weak_view.clone();
|
||||||
|
|
||||||
this.confirm()
|
this.confirm()
|
||||||
.title(shared_t!("chat.subject_tooltip"))
|
.title("Change the subject of the conversation")
|
||||||
.child(view.clone())
|
.child(view.clone())
|
||||||
.button_props(ModalButtonProps::default().ok_text(t!("common.change")))
|
.button_props(ModalButtonProps::default().ok_text("Change"))
|
||||||
.on_ok(move |_, _window, cx| {
|
.on_ok(move |_, _window, cx| {
|
||||||
if let Ok(subject) =
|
if let Ok(subject) =
|
||||||
weak_view.read_with(cx, |this, cx| this.new_subject(cx))
|
weak_view.read_with(cx, |this, cx| this.new_subject(cx))
|
||||||
@@ -1224,22 +1228,23 @@ impl Chat {
|
|||||||
|
|
||||||
Button::new("reload")
|
Button::new("reload")
|
||||||
.icon(IconName::Refresh)
|
.icon(IconName::Refresh)
|
||||||
.tooltip(t!("chat.reload_tooltip"))
|
.tooltip("Reload")
|
||||||
.on_click(move |_, window, cx| {
|
.on_click(move |_ev, window, cx| {
|
||||||
window.push_notification(t!("common.refreshed"), cx);
|
_ = room.update(cx, |this, cx| {
|
||||||
room.update(cx, |this, cx| {
|
|
||||||
this.emit_refresh(cx);
|
this.emit_refresh(cx);
|
||||||
})
|
window.push_notification("Reloaded", cx);
|
||||||
.ok();
|
});
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn on_open_seen_on(&mut self, ev: &SeenOn, window: &mut Window, cx: &mut Context<Self>) {
|
fn on_open_seen_on(&mut self, ev: &SeenOn, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
let id = ev.0;
|
let id = ev.0;
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let client = nostr.read(cx).client();
|
||||||
|
let tracker = nostr.read(cx).tracker();
|
||||||
|
|
||||||
let task: Task<Result<Vec<RelayUrl>, Error>> = cx.background_spawn(async move {
|
let task: Task<Result<Vec<RelayUrl>, Error>> = cx.background_spawn(async move {
|
||||||
let client = nostr_client();
|
let tracker = tracker.read().await;
|
||||||
let app_state = app_state();
|
|
||||||
let mut relays: Vec<RelayUrl> = vec![];
|
let mut relays: Vec<RelayUrl> = vec![];
|
||||||
|
|
||||||
let filter = Filter::new()
|
let filter = Filter::new()
|
||||||
@@ -1249,7 +1254,7 @@ impl Chat {
|
|||||||
|
|
||||||
if let Some(event) = client.database().query(filter).await?.first_owned() {
|
if let Some(event) = client.database().query(filter).await?.first_owned() {
|
||||||
if let Some(Ok(id)) = event.tags.identifier().map(EventId::parse) {
|
if let Some(Ok(id)) = event.tags.identifier().map(EventId::parse) {
|
||||||
if let Some(urls) = app_state.seen_on_relays.read().await.get(&id).cloned() {
|
if let Some(urls) = tracker.seen_on_relays.get(&id).cloned() {
|
||||||
relays.extend(urls);
|
relays.extend(urls);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1262,8 +1267,9 @@ impl Chat {
|
|||||||
if let Ok(urls) = task.await {
|
if let Ok(urls) = task.await {
|
||||||
cx.update(|window, cx| {
|
cx.update(|window, cx| {
|
||||||
window.open_modal(cx, move |this, _window, cx| {
|
window.open_modal(cx, move |this, _window, cx| {
|
||||||
this.title(shared_t!("common.seen_on")).child(
|
this.show_close(true)
|
||||||
v_flex().pb_4().gap_2().children({
|
.title(SharedString::from("Seen on"))
|
||||||
|
.child(v_flex().pb_4().gap_2().children({
|
||||||
let mut items = Vec::with_capacity(urls.len());
|
let mut items = Vec::with_capacity(urls.len());
|
||||||
|
|
||||||
for url in urls.clone().into_iter() {
|
for url in urls.clone().into_iter() {
|
||||||
@@ -1275,13 +1281,12 @@ impl Chat {
|
|||||||
.rounded(cx.theme().radius)
|
.rounded(cx.theme().radius)
|
||||||
.font_semibold()
|
.font_semibold()
|
||||||
.text_xs()
|
.text_xs()
|
||||||
.child(url.to_string()),
|
.child(SharedString::from(url.to_string())),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
items
|
items
|
||||||
}),
|
}))
|
||||||
)
|
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.ok();
|
.ok();
|
||||||
@@ -1289,9 +1294,16 @@ impl Chat {
|
|||||||
})
|
})
|
||||||
.detach();
|
.detach();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn on_set_encryption(&mut self, ev: &SetSigner, _: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
self.options.update(cx, move |this, cx| {
|
||||||
|
this.signer_kind = ev.0;
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Panel for Chat {
|
impl Panel for ChatPanel {
|
||||||
fn panel_id(&self) -> SharedString {
|
fn panel_id(&self) -> SharedString {
|
||||||
self.id.clone()
|
self.id.clone()
|
||||||
}
|
}
|
||||||
@@ -1310,10 +1322,6 @@ impl Panel for Chat {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn popup_menu(&self, menu: PopupMenu, _cx: &App) -> PopupMenu {
|
|
||||||
menu.track_focus(&self.focus_handle)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn toolbar_buttons(&self, _window: &Window, cx: &App) -> Vec<Button> {
|
fn toolbar_buttons(&self, _window: &Window, cx: &App) -> Vec<Button> {
|
||||||
let subject_button = self.subject_button(cx);
|
let subject_button = self.subject_button(cx);
|
||||||
let reload_button = self.reload_button(cx);
|
let reload_button = self.reload_button(cx);
|
||||||
@@ -1322,43 +1330,29 @@ impl Panel for Chat {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EventEmitter<PanelEvent> for Chat {}
|
impl EventEmitter<PanelEvent> for ChatPanel {}
|
||||||
|
|
||||||
impl Focusable for Chat {
|
impl Focusable for ChatPanel {
|
||||||
fn focus_handle(&self, _: &App) -> FocusHandle {
|
fn focus_handle(&self, _: &App) -> FocusHandle {
|
||||||
self.focus_handle.clone()
|
self.focus_handle.clone()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Render for Chat {
|
impl Render for ChatPanel {
|
||||||
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 {
|
||||||
|
let kind = self.signer_kind(cx);
|
||||||
|
|
||||||
v_flex()
|
v_flex()
|
||||||
.on_action(cx.listener(Self::on_open_seen_on))
|
.on_action(cx.listener(Self::on_open_seen_on))
|
||||||
|
.on_action(cx.listener(Self::on_set_encryption))
|
||||||
.image_cache(self.image_cache.clone())
|
.image_cache(self.image_cache.clone())
|
||||||
.size_full()
|
.size_full()
|
||||||
.child(
|
.child(
|
||||||
list(
|
list(
|
||||||
self.list_state.clone(),
|
self.list_state.clone(),
|
||||||
cx.processor(move |this, ix: usize, window, cx| {
|
cx.processor(|this, ix, window, cx| {
|
||||||
if let Some(message) = this.messages.get_index(ix) {
|
// Get and render message by index
|
||||||
match message {
|
this.render_message(ix, window, cx)
|
||||||
Message::User(rendered) => {
|
|
||||||
let text = this
|
|
||||||
.rendered_texts_by_id
|
|
||||||
.entry(rendered.id)
|
|
||||||
.or_insert_with(|| RenderedText::new(&rendered.content, cx))
|
|
||||||
.element(ix.into(), window, cx);
|
|
||||||
|
|
||||||
this.render_message(ix, rendered, text, cx)
|
|
||||||
}
|
|
||||||
Message::Warning(content, _timestamp) => {
|
|
||||||
this.render_warning(ix, SharedString::from(content), cx)
|
|
||||||
}
|
|
||||||
Message::System(_timestamp) => this.render_announcement(ix, cx),
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
this.render_warning(ix, shared_t!("chat.not_found"), cx)
|
|
||||||
}
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.flex_1(),
|
.flex_1(),
|
||||||
@@ -1382,9 +1376,7 @@ impl Render for Chat {
|
|||||||
.items_end()
|
.items_end()
|
||||||
.gap_2p5()
|
.gap_2p5()
|
||||||
.child(
|
.child(
|
||||||
div()
|
h_flex()
|
||||||
.flex()
|
|
||||||
.items_center()
|
|
||||||
.gap_1()
|
.gap_1()
|
||||||
.text_color(cx.theme().text_muted)
|
.text_color(cx.theme().text_muted)
|
||||||
.child(
|
.child(
|
||||||
@@ -1401,12 +1393,37 @@ impl Render for Chat {
|
|||||||
)),
|
)),
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
EmojiPicker::new(self.input.downgrade())
|
EmojiPicker::new()
|
||||||
|
.target(self.input.downgrade())
|
||||||
.icon(IconName::EmojiFill)
|
.icon(IconName::EmojiFill)
|
||||||
.large(),
|
.large(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.child(TextInput::new(&self.input)),
|
.child(TextInput::new(&self.input))
|
||||||
|
.child(
|
||||||
|
Button::new("encryptions")
|
||||||
|
.icon(IconName::Encryption)
|
||||||
|
.ghost()
|
||||||
|
.large()
|
||||||
|
.popup_menu(move |this, _window, _cx| {
|
||||||
|
this.label("Encrypt by:")
|
||||||
|
.menu_with_check(
|
||||||
|
"Encryption Key",
|
||||||
|
matches!(kind, SignerKind::Encryption),
|
||||||
|
Box::new(SetSigner(SignerKind::Encryption)),
|
||||||
|
)
|
||||||
|
.menu_with_check(
|
||||||
|
"User's Identity",
|
||||||
|
matches!(kind, SignerKind::User),
|
||||||
|
Box::new(SetSigner(SignerKind::User)),
|
||||||
|
)
|
||||||
|
.menu_with_check(
|
||||||
|
"Auto",
|
||||||
|
matches!(kind, SignerKind::Auto),
|
||||||
|
Box::new(SetSigner(SignerKind::Auto)),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
use gpui::{
|
||||||
|
div, App, AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString,
|
||||||
|
Styled, Window,
|
||||||
|
};
|
||||||
|
use theme::ActiveTheme;
|
||||||
|
use ui::input::{InputState, TextInput};
|
||||||
|
use ui::{v_flex, Sizable};
|
||||||
|
|
||||||
|
pub fn init(subject: Option<String>, window: &mut Window, cx: &mut App) -> Entity<Subject> {
|
||||||
|
cx.new(|cx| Subject::new(subject, window, cx))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Subject {
|
||||||
|
input: Entity<InputState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Subject {
|
||||||
|
pub fn new(subject: Option<String>, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||||
|
let input = cx.new(|cx| InputState::new(window, cx).placeholder("Plan for holiday"));
|
||||||
|
|
||||||
|
if let Some(value) = subject {
|
||||||
|
input.update(cx, |this, cx| {
|
||||||
|
this.set_value(value, window, cx);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
Self { input }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new_subject(&self, cx: &App) -> SharedString {
|
||||||
|
self.input.read(cx).value()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for Subject {
|
||||||
|
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
v_flex()
|
||||||
|
.gap_2()
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.gap_1()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_sm()
|
||||||
|
.text_color(cx.theme().text_muted)
|
||||||
|
.child(SharedString::from("Subject:")),
|
||||||
|
)
|
||||||
|
.child(TextInput::new(&self.input).small()),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
|
.italic()
|
||||||
|
.text_color(cx.theme().text_placeholder)
|
||||||
|
.child(SharedString::from(
|
||||||
|
"Subject will be updated when you send a new message.",
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,22 +1,21 @@
|
|||||||
use std::ops::Range;
|
use std::ops::Range;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use common::display::RenderedProfile;
|
use common::RenderedProfile;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, AnyView, App, ElementId, HighlightStyle, InteractiveText, IntoElement,
|
AnyElement, App, ElementId, HighlightStyle, InteractiveText, IntoElement, SharedString,
|
||||||
SharedString, StyledText, UnderlineStyle, Window,
|
StyledText, UnderlineStyle, Window,
|
||||||
};
|
};
|
||||||
use linkify::{LinkFinder, LinkKind};
|
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use once_cell::sync::Lazy;
|
use once_cell::sync::Lazy;
|
||||||
|
use person::PersonRegistry;
|
||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
use registry::Registry;
|
|
||||||
use theme::ActiveTheme;
|
use theme::ActiveTheme;
|
||||||
|
|
||||||
use crate::actions::OpenPublicKey;
|
use crate::actions::OpenPublicKey;
|
||||||
|
|
||||||
static URL_REGEX: Lazy<Regex> = Lazy::new(|| {
|
static URL_REGEX: Lazy<Regex> = Lazy::new(|| {
|
||||||
Regex::new(r"^(?:[a-zA-Z]+://)?([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}(:\d+)?(/.*)?$").unwrap()
|
Regex::new(r"(?i)(?:^|\s)(?:https?://)?(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}(?::\d+)?(?:/[^\s]*)?(?:\s|$)").unwrap()
|
||||||
});
|
});
|
||||||
|
|
||||||
static NOSTR_URI_REGEX: Lazy<Regex> =
|
static NOSTR_URI_REGEX: Lazy<Regex> =
|
||||||
@@ -24,43 +23,16 @@ static NOSTR_URI_REGEX: Lazy<Regex> =
|
|||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum Highlight {
|
pub enum Highlight {
|
||||||
Link(HighlightStyle),
|
Link,
|
||||||
Nostr,
|
Nostr,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Highlight {
|
|
||||||
fn link() -> Self {
|
|
||||||
Self::Link(HighlightStyle {
|
|
||||||
underline: Some(UnderlineStyle {
|
|
||||||
thickness: 1.0.into(),
|
|
||||||
..Default::default()
|
|
||||||
}),
|
|
||||||
..Default::default()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn nostr() -> Self {
|
|
||||||
Self::Nostr
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<HighlightStyle> for Highlight {
|
|
||||||
fn from(style: HighlightStyle) -> Self {
|
|
||||||
Self::Link(style)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type CustomRangeTooltipFn =
|
|
||||||
Option<Arc<dyn Fn(usize, Range<usize>, &mut Window, &mut App) -> Option<AnyView>>>;
|
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct RenderedText {
|
pub struct RenderedText {
|
||||||
pub text: SharedString,
|
pub text: SharedString,
|
||||||
pub highlights: Vec<(Range<usize>, Highlight)>,
|
pub highlights: Vec<(Range<usize>, Highlight)>,
|
||||||
pub link_ranges: Vec<Range<usize>>,
|
pub link_ranges: Vec<Range<usize>>,
|
||||||
pub link_urls: Arc<[String]>,
|
pub link_urls: Arc<[String]>,
|
||||||
pub custom_ranges: Vec<Range<usize>>,
|
|
||||||
custom_ranges_tooltip_fn: CustomRangeTooltipFn,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RenderedText {
|
impl RenderedText {
|
||||||
@@ -86,18 +58,9 @@ impl RenderedText {
|
|||||||
link_urls: link_urls.into(),
|
link_urls: link_urls.into(),
|
||||||
link_ranges,
|
link_ranges,
|
||||||
highlights,
|
highlights,
|
||||||
custom_ranges: Vec::new(),
|
|
||||||
custom_ranges_tooltip_fn: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_tooltip_builder_for_custom_ranges<F>(&mut self, f: F)
|
|
||||||
where
|
|
||||||
F: Fn(usize, Range<usize>, &mut Window, &mut App) -> Option<AnyView> + 'static,
|
|
||||||
{
|
|
||||||
self.custom_ranges_tooltip_fn = Some(Arc::new(f));
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn element(&self, id: ElementId, window: &Window, cx: &App) -> AnyElement {
|
pub fn element(&self, id: ElementId, window: &Window, cx: &App) -> AnyElement {
|
||||||
let link_color = cx.theme().text_accent;
|
let link_color = cx.theme().text_accent;
|
||||||
|
|
||||||
@@ -109,17 +72,11 @@ impl RenderedText {
|
|||||||
(
|
(
|
||||||
range.clone(),
|
range.clone(),
|
||||||
match highlight {
|
match highlight {
|
||||||
Highlight::Link(highlight) => {
|
Highlight::Link => HighlightStyle {
|
||||||
// Check if this is a link highlight by seeing if it has an underline
|
color: Some(link_color),
|
||||||
if highlight.underline.is_some() {
|
underline: Some(UnderlineStyle::default()),
|
||||||
// It's a link, so apply the link color
|
..Default::default()
|
||||||
let mut link_style = *highlight;
|
},
|
||||||
link_style.color = Some(link_color);
|
|
||||||
link_style
|
|
||||||
} else {
|
|
||||||
*highlight
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Highlight::Nostr => HighlightStyle {
|
Highlight::Nostr => HighlightStyle {
|
||||||
color: Some(link_color),
|
color: Some(link_color),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -134,49 +91,22 @@ impl RenderedText {
|
|||||||
move |ix, window, cx| {
|
move |ix, window, cx| {
|
||||||
let token = link_urls[ix].as_str();
|
let token = link_urls[ix].as_str();
|
||||||
|
|
||||||
if token.starts_with("nostr:") {
|
if let Some(clean_url) = token.strip_prefix("nostr:") {
|
||||||
let clean_url = token.replace("nostr:", "");
|
if let Ok(public_key) = PublicKey::parse(clean_url) {
|
||||||
let Ok(public_key) = PublicKey::parse(&clean_url) else {
|
|
||||||
log::error!("Failed to parse public key from: {clean_url}");
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
window.dispatch_action(Box::new(OpenPublicKey(public_key)), cx);
|
window.dispatch_action(Box::new(OpenPublicKey(public_key)), cx);
|
||||||
} else if is_url(token) {
|
|
||||||
if !token.starts_with("http") {
|
|
||||||
cx.open_url(&format!("https://{token}"));
|
|
||||||
} else {
|
|
||||||
cx.open_url(token);
|
|
||||||
}
|
}
|
||||||
|
} else if is_url(token) {
|
||||||
|
let url = if token.starts_with("http") {
|
||||||
|
token.to_string()
|
||||||
|
} else {
|
||||||
|
format!("https://{token}")
|
||||||
|
};
|
||||||
|
cx.open_url(&url);
|
||||||
} else {
|
} else {
|
||||||
log::warn!("Unrecognized token {token}")
|
log::warn!("Unrecognized token {token}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.tooltip({
|
|
||||||
let link_ranges = self.link_ranges.clone();
|
|
||||||
let link_urls = self.link_urls.clone();
|
|
||||||
let custom_tooltip_ranges = self.custom_ranges.clone();
|
|
||||||
let custom_tooltip_fn = self.custom_ranges_tooltip_fn.clone();
|
|
||||||
move |idx, window, cx| {
|
|
||||||
for (ix, range) in link_ranges.iter().enumerate() {
|
|
||||||
if range.contains(&idx) {
|
|
||||||
let url = &link_urls[ix];
|
|
||||||
if url.starts_with("http") {
|
|
||||||
// return Some(LinkPreview::new(url, cx));
|
|
||||||
}
|
|
||||||
// You can add custom tooltip handling for mentions here
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for range in &custom_tooltip_ranges {
|
|
||||||
if range.contains(&idx) {
|
|
||||||
if let Some(f) = &custom_tooltip_fn {
|
|
||||||
return f(idx, range.clone(), window, cx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -192,18 +122,11 @@ fn render_plain_text_mut(
|
|||||||
// Copy the content directly
|
// Copy the content directly
|
||||||
text.push_str(content);
|
text.push_str(content);
|
||||||
|
|
||||||
// Initialize the link finder
|
|
||||||
let mut finder = LinkFinder::new();
|
|
||||||
finder.url_must_have_scheme(false);
|
|
||||||
finder.kinds(&[LinkKind::Url]);
|
|
||||||
|
|
||||||
// Collect all URLs
|
// Collect all URLs
|
||||||
let mut url_matches: Vec<(Range<usize>, String)> = Vec::new();
|
let mut url_matches: Vec<(Range<usize>, String)> = Vec::new();
|
||||||
|
|
||||||
for link in finder.links(content) {
|
for link in URL_REGEX.find_iter(content) {
|
||||||
let start = link.start();
|
let range = link.start()..link.end();
|
||||||
let end = link.end();
|
|
||||||
let range = start..end;
|
|
||||||
let url = link.as_str().to_string();
|
let url = link.as_str().to_string();
|
||||||
|
|
||||||
url_matches.push((range, url));
|
url_matches.push((range, url));
|
||||||
@@ -213,9 +136,7 @@ fn render_plain_text_mut(
|
|||||||
let mut nostr_matches: Vec<(Range<usize>, String)> = Vec::new();
|
let mut nostr_matches: Vec<(Range<usize>, String)> = Vec::new();
|
||||||
|
|
||||||
for nostr_match in NOSTR_URI_REGEX.find_iter(content) {
|
for nostr_match in NOSTR_URI_REGEX.find_iter(content) {
|
||||||
let start = nostr_match.start();
|
let range = nostr_match.start()..nostr_match.end();
|
||||||
let end = nostr_match.end();
|
|
||||||
let range = start..end;
|
|
||||||
let nostr_uri = nostr_match.as_str().to_string();
|
let nostr_uri = nostr_match.as_str().to_string();
|
||||||
|
|
||||||
// Check if this nostr URI overlaps with any already processed URL
|
// Check if this nostr URI overlaps with any already processed URL
|
||||||
@@ -239,12 +160,9 @@ fn render_plain_text_mut(
|
|||||||
for (range, entity) in all_matches {
|
for (range, entity) in all_matches {
|
||||||
// Handle URL token
|
// Handle URL token
|
||||||
if is_url(&entity) {
|
if is_url(&entity) {
|
||||||
// Add underline highlight
|
highlights.push((range.clone(), Highlight::Link));
|
||||||
highlights.push((range.clone(), Highlight::link()));
|
|
||||||
// Make it clickable
|
|
||||||
link_ranges.push(range);
|
link_ranges.push(range);
|
||||||
link_urls.push(entity);
|
link_urls.push(entity);
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -305,75 +223,6 @@ fn render_plain_text_mut(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_pubkey(
|
|
||||||
public_key: PublicKey,
|
|
||||||
text: &mut String,
|
|
||||||
range: &Range<usize>,
|
|
||||||
highlights: &mut Vec<(Range<usize>, Highlight)>,
|
|
||||||
link_ranges: &mut Vec<Range<usize>>,
|
|
||||||
link_urls: &mut Vec<String>,
|
|
||||||
cx: &App,
|
|
||||||
) {
|
|
||||||
let registry = Registry::read_global(cx);
|
|
||||||
let profile = registry.get_person(&public_key, cx);
|
|
||||||
let display_name = format!("@{}", profile.display_name());
|
|
||||||
|
|
||||||
// Replace token with display name
|
|
||||||
text.replace_range(range.clone(), &display_name);
|
|
||||||
|
|
||||||
// Adjust ranges
|
|
||||||
let new_length = display_name.len();
|
|
||||||
let length_diff = new_length as isize - (range.end - range.start) as isize;
|
|
||||||
// New range for the replacement
|
|
||||||
let new_range = range.start..(range.start + new_length);
|
|
||||||
|
|
||||||
// Add highlight for the profile name
|
|
||||||
highlights.push((new_range.clone(), Highlight::nostr()));
|
|
||||||
// Make it clickable
|
|
||||||
link_ranges.push(new_range);
|
|
||||||
link_urls.push(format!("nostr:{}", profile.public_key().to_hex()));
|
|
||||||
|
|
||||||
// Adjust subsequent ranges if needed
|
|
||||||
if length_diff != 0 {
|
|
||||||
adjust_ranges(highlights, link_ranges, range.end, length_diff);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_bech32(
|
|
||||||
bech32: String,
|
|
||||||
text: &mut String,
|
|
||||||
range: &Range<usize>,
|
|
||||||
highlights: &mut Vec<(Range<usize>, Highlight)>,
|
|
||||||
link_ranges: &mut Vec<Range<usize>>,
|
|
||||||
link_urls: &mut Vec<String>,
|
|
||||||
) {
|
|
||||||
let njump_url = format!("https://njump.me/{bech32}");
|
|
||||||
|
|
||||||
// Create a shortened display format for the URL
|
|
||||||
let shortened_entity = format_shortened_entity(&bech32);
|
|
||||||
let display_text = format!("https://njump.me/{shortened_entity}");
|
|
||||||
|
|
||||||
// Replace the original entity with the shortened display version
|
|
||||||
text.replace_range(range.clone(), &display_text);
|
|
||||||
|
|
||||||
// Adjust the ranges
|
|
||||||
let new_length = display_text.len();
|
|
||||||
let length_diff = new_length as isize - (range.end - range.start) as isize;
|
|
||||||
// New range for the replacement
|
|
||||||
let new_range = range.start..(range.start + new_length);
|
|
||||||
|
|
||||||
// Add underline highlight
|
|
||||||
highlights.push((new_range.clone(), Highlight::link()));
|
|
||||||
// Make it clickable
|
|
||||||
link_ranges.push(new_range);
|
|
||||||
link_urls.push(njump_url);
|
|
||||||
|
|
||||||
// Adjust subsequent ranges if needed
|
|
||||||
if length_diff != 0 {
|
|
||||||
adjust_ranges(highlights, link_ranges, range.end, length_diff);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if a string is a URL
|
/// Check if a string is a URL
|
||||||
@@ -395,6 +244,61 @@ fn format_shortened_entity(entity: &str) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn render_pubkey(
|
||||||
|
public_key: PublicKey,
|
||||||
|
text: &mut String,
|
||||||
|
range: &Range<usize>,
|
||||||
|
highlights: &mut Vec<(Range<usize>, Highlight)>,
|
||||||
|
link_ranges: &mut Vec<Range<usize>>,
|
||||||
|
link_urls: &mut Vec<String>,
|
||||||
|
cx: &App,
|
||||||
|
) {
|
||||||
|
let persons = PersonRegistry::global(cx);
|
||||||
|
let profile = persons.read(cx).get_person(&public_key, cx);
|
||||||
|
let display_name = format!("@{}", profile.display_name());
|
||||||
|
|
||||||
|
text.replace_range(range.clone(), &display_name);
|
||||||
|
|
||||||
|
let new_length = display_name.len();
|
||||||
|
let length_diff = new_length as isize - (range.end - range.start) as isize;
|
||||||
|
let new_range = range.start..(range.start + new_length);
|
||||||
|
|
||||||
|
highlights.push((new_range.clone(), Highlight::Nostr));
|
||||||
|
link_ranges.push(new_range);
|
||||||
|
link_urls.push(format!("nostr:{}", profile.public_key().to_hex()));
|
||||||
|
|
||||||
|
if length_diff != 0 {
|
||||||
|
adjust_ranges(highlights, link_ranges, range.end, length_diff);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_bech32(
|
||||||
|
bech32: String,
|
||||||
|
text: &mut String,
|
||||||
|
range: &Range<usize>,
|
||||||
|
highlights: &mut Vec<(Range<usize>, Highlight)>,
|
||||||
|
link_ranges: &mut Vec<Range<usize>>,
|
||||||
|
link_urls: &mut Vec<String>,
|
||||||
|
) {
|
||||||
|
let njump_url = format!("https://njump.me/{bech32}");
|
||||||
|
let shortened_entity = format_shortened_entity(&bech32);
|
||||||
|
let display_text = format!("https://njump.me/{shortened_entity}");
|
||||||
|
|
||||||
|
text.replace_range(range.clone(), &display_text);
|
||||||
|
|
||||||
|
let new_length = display_text.len();
|
||||||
|
let length_diff = new_length as isize - (range.end - range.start) as isize;
|
||||||
|
let new_range = range.start..(range.start + new_length);
|
||||||
|
|
||||||
|
highlights.push((new_range.clone(), Highlight::Link));
|
||||||
|
link_ranges.push(new_range);
|
||||||
|
link_urls.push(njump_url);
|
||||||
|
|
||||||
|
if length_diff != 0 {
|
||||||
|
adjust_ranges(highlights, link_ranges, range.end, length_diff);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Helper function to adjust ranges when text length changes
|
// Helper function to adjust ranges when text length changes
|
||||||
fn adjust_ranges(
|
fn adjust_ranges(
|
||||||
highlights: &mut [(Range<usize>, Highlight)],
|
highlights: &mut [(Range<usize>, Highlight)],
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
use std::sync::atomic::Ordering;
|
|
||||||
|
|
||||||
use global::app_state;
|
|
||||||
use global::constants::KEYRING_URL;
|
|
||||||
use gpui::{App, AppContext, Context, Entity, Global, Subscription, Window};
|
|
||||||
use nostr_sdk::prelude::*;
|
|
||||||
use smallvec::{smallvec, SmallVec};
|
|
||||||
|
|
||||||
pub fn init(cx: &mut App) {
|
|
||||||
ClientKeys::set_global(cx.new(ClientKeys::new), cx);
|
|
||||||
}
|
|
||||||
|
|
||||||
struct GlobalClientKeys(Entity<ClientKeys>);
|
|
||||||
|
|
||||||
impl Global for GlobalClientKeys {}
|
|
||||||
|
|
||||||
pub struct ClientKeys {
|
|
||||||
keys: Option<Keys>,
|
|
||||||
#[allow(dead_code)]
|
|
||||||
subscriptions: SmallVec<[Subscription; 1]>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ClientKeys {
|
|
||||||
/// Retrieve the Global Client Keys instance
|
|
||||||
pub fn global(cx: &App) -> Entity<Self> {
|
|
||||||
cx.global::<GlobalClientKeys>().0.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Retrieve the Client Keys instance
|
|
||||||
pub fn read_global(cx: &App) -> &Self {
|
|
||||||
cx.global::<GlobalClientKeys>().0.read(cx)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set the Global Client Keys instance
|
|
||||||
pub(crate) fn set_global(state: Entity<Self>, cx: &mut App) {
|
|
||||||
cx.set_global(GlobalClientKeys(state));
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn new(cx: &mut Context<Self>) -> Self {
|
|
||||||
let mut subscriptions = smallvec![];
|
|
||||||
|
|
||||||
subscriptions.push(cx.observe_new::<Self>(|this, window, cx| {
|
|
||||||
if let Some(window) = window {
|
|
||||||
this.load(window, cx);
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
Self {
|
|
||||||
keys: None,
|
|
||||||
subscriptions,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
||||||
// Prevent macOS from asking for password every time
|
|
||||||
// Only for debug builds
|
|
||||||
if cfg!(debug_assertions) && cfg!(target_os = "macos") {
|
|
||||||
log::warn!("Running debug build on macOS");
|
|
||||||
log::warn!("Skipping keychain access, generating new client keys");
|
|
||||||
self.new_keys(cx);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let app_state = app_state();
|
|
||||||
let read_client_keys = cx.read_credentials(KEYRING_URL);
|
|
||||||
|
|
||||||
cx.spawn_in(window, async move |this, cx| {
|
|
||||||
if let Ok(Some((_, secret))) = read_client_keys.await {
|
|
||||||
// Update the client keys with the stored secret key from the keychain
|
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
let Ok(secret_key) = SecretKey::from_slice(&secret) else {
|
|
||||||
this.set_keys(None, false, true, cx);
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let keys = Keys::new(secret_key);
|
|
||||||
this.set_keys(Some(keys), false, true, cx);
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
} else if app_state.is_first_run.load(Ordering::Acquire) {
|
|
||||||
// If this is the first run, generate new keys and use them for the client keys
|
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
this.new_keys(cx);
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
} else {
|
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
this.set_keys(None, false, true, cx);
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.detach();
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn set_keys(
|
|
||||||
&mut self,
|
|
||||||
keys: Option<Keys>,
|
|
||||||
persist: bool,
|
|
||||||
notify: bool,
|
|
||||||
cx: &mut Context<Self>,
|
|
||||||
) {
|
|
||||||
if persist {
|
|
||||||
if let Some(keys) = keys.as_ref() {
|
|
||||||
let username = keys.public_key().to_hex();
|
|
||||||
let password = keys.secret_key().secret_bytes();
|
|
||||||
let write_keys = cx.write_credentials(KEYRING_URL, &username, &password);
|
|
||||||
|
|
||||||
cx.background_spawn(async move {
|
|
||||||
if let Err(e) = write_keys.await {
|
|
||||||
log::error!("Failed to save the client keys: {e}")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.detach();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
self.keys = keys;
|
|
||||||
|
|
||||||
// Notify GPUI to reload UI
|
|
||||||
if notify {
|
|
||||||
cx.notify();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn new_keys(&mut self, cx: &mut Context<Self>) {
|
|
||||||
self.set_keys(Some(Keys::generate()), true, true, cx);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn force_new_keys(&mut self, cx: &mut Context<Self>) {
|
|
||||||
self.set_keys(Some(Keys::generate()), true, false, cx);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn keys(&self) -> Keys {
|
|
||||||
self.keys
|
|
||||||
.clone()
|
|
||||||
.expect("Keys should always be initialized")
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn has_keys(&self) -> bool {
|
|
||||||
self.keys.is_some()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,12 +5,9 @@ edition.workspace = true
|
|||||||
publish.workspace = true
|
publish.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
global = { path = "../global" }
|
|
||||||
|
|
||||||
gpui.workspace = true
|
gpui.workspace = true
|
||||||
nostr-connect.workspace = true
|
|
||||||
nostr-sdk.workspace = true
|
nostr-sdk.workspace = true
|
||||||
nostr.workspace = true
|
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
itertools.workspace = true
|
itertools.workspace = true
|
||||||
chrono.workspace = true
|
chrono.workspace = true
|
||||||
@@ -19,6 +16,8 @@ smol.workspace = true
|
|||||||
futures.workspace = true
|
futures.workspace = true
|
||||||
reqwest.workspace = true
|
reqwest.workspace = true
|
||||||
log.workspace = true
|
log.workspace = true
|
||||||
webbrowser.workspace = true
|
|
||||||
|
|
||||||
|
dirs = "5.0"
|
||||||
qrcode = "0.14.1"
|
qrcode = "0.14.1"
|
||||||
|
whoami = "1.6.1"
|
||||||
|
nostr = { git = "https://github.com/rust-nostr/nostr" }
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
pub const CLIENT_NAME: &str = "Coop";
|
||||||
|
pub const APP_ID: &str = "su.reya.coop";
|
||||||
|
|
||||||
|
/// Bootstrap Relays.
|
||||||
|
pub const BOOTSTRAP_RELAYS: [&str; 5] = [
|
||||||
|
"wss://relay.damus.io",
|
||||||
|
"wss://relay.primal.net",
|
||||||
|
"wss://relay.nos.social",
|
||||||
|
"wss://user.kindpag.es",
|
||||||
|
"wss://purplepag.es",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Search Relays.
|
||||||
|
pub const SEARCH_RELAYS: [&str; 3] = [
|
||||||
|
"wss://relay.nostr.band",
|
||||||
|
"wss://search.nos.today",
|
||||||
|
"wss://relay.noswhere.com",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Default relay for Nostr Connect
|
||||||
|
pub const NOSTR_CONNECT_RELAY: &str = "wss://relay.nsec.app";
|
||||||
|
|
||||||
|
/// Default retry count for fetching NIP-17 relays
|
||||||
|
pub const RELAY_RETRY: u64 = 2;
|
||||||
|
|
||||||
|
/// Default retry count for sending messages
|
||||||
|
pub const SEND_RETRY: u64 = 10;
|
||||||
|
|
||||||
|
/// Default timeout (in seconds) for Nostr Connect
|
||||||
|
pub const NOSTR_CONNECT_TIMEOUT: u64 = 200;
|
||||||
|
|
||||||
|
/// Default timeout (in seconds) for Nostr Connect (Bunker)
|
||||||
|
pub const BUNKER_TIMEOUT: u64 = 30;
|
||||||
|
|
||||||
|
/// Total metadata requests will be grouped.
|
||||||
|
pub const METADATA_BATCH_LIMIT: usize = 20;
|
||||||
|
|
||||||
|
/// Default width of the sidebar.
|
||||||
|
pub const DEFAULT_SIDEBAR_WIDTH: f32 = 240.;
|
||||||
@@ -2,8 +2,7 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use anyhow::{anyhow, Error};
|
use anyhow::{anyhow, Error};
|
||||||
use chrono::{Local, TimeZone};
|
use chrono::{Local, TimeZone};
|
||||||
use global::constants::IMAGE_RESIZE_SERVICE;
|
use gpui::{Image, ImageFormat, SharedString};
|
||||||
use gpui::{Image, ImageFormat, SharedString, SharedUri};
|
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use qrcode::render::svg;
|
use qrcode::render::svg;
|
||||||
use qrcode::QrCode;
|
use qrcode::QrCode;
|
||||||
@@ -14,14 +13,15 @@ const MINUTES_IN_HOUR: i64 = 60;
|
|||||||
const HOURS_IN_DAY: i64 = 24;
|
const HOURS_IN_DAY: i64 = 24;
|
||||||
const DAYS_IN_MONTH: i64 = 30;
|
const DAYS_IN_MONTH: i64 = 30;
|
||||||
const FALLBACK_IMG: &str = "https://image.nostr.build/c30703b48f511c293a9003be8100cdad37b8798b77a1dc3ec6eb8a20443d5dea.png";
|
const FALLBACK_IMG: &str = "https://image.nostr.build/c30703b48f511c293a9003be8100cdad37b8798b77a1dc3ec6eb8a20443d5dea.png";
|
||||||
|
const IMAGE_RESIZE_SERVICE: &str = "https://wsrv.nl";
|
||||||
|
|
||||||
pub trait RenderedProfile {
|
pub trait RenderedProfile {
|
||||||
fn avatar(&self, proxy: bool) -> SharedUri;
|
fn avatar(&self, proxy: bool) -> SharedString;
|
||||||
fn display_name(&self) -> SharedString;
|
fn display_name(&self) -> SharedString;
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RenderedProfile for Profile {
|
impl RenderedProfile for Profile {
|
||||||
fn avatar(&self, proxy: bool) -> SharedUri {
|
fn avatar(&self, proxy: bool) -> SharedString {
|
||||||
self.metadata()
|
self.metadata()
|
||||||
.picture
|
.picture
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -32,12 +32,12 @@ impl RenderedProfile for Profile {
|
|||||||
"{IMAGE_RESIZE_SERVICE}/?url={picture}&w=100&h=100&fit=cover&mask=circle&default={FALLBACK_IMG}&n=-1"
|
"{IMAGE_RESIZE_SERVICE}/?url={picture}&w=100&h=100&fit=cover&mask=circle&default={FALLBACK_IMG}&n=-1"
|
||||||
);
|
);
|
||||||
|
|
||||||
SharedUri::from(url)
|
url.into()
|
||||||
} else {
|
} else {
|
||||||
SharedUri::from(picture)
|
picture.into()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.unwrap_or_else(|| SharedUri::from("brand/avatar.png"))
|
.unwrap_or_else(|| "brand/avatar.png".into())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn display_name(&self) -> SharedString {
|
fn display_name(&self) -> SharedString {
|
||||||
@@ -64,7 +64,7 @@ pub trait RenderedTimestamp {
|
|||||||
|
|
||||||
impl RenderedTimestamp for Timestamp {
|
impl RenderedTimestamp for Timestamp {
|
||||||
fn to_human_time(&self) -> SharedString {
|
fn to_human_time(&self) -> SharedString {
|
||||||
let input_time = match Local.timestamp_opt(self.as_u64() as i64, 0) {
|
let input_time = match Local.timestamp_opt(self.as_secs() as i64, 0) {
|
||||||
chrono::LocalResult::Single(time) => time,
|
chrono::LocalResult::Single(time) => time,
|
||||||
_ => return SharedString::from("9999"),
|
_ => return SharedString::from("9999"),
|
||||||
};
|
};
|
||||||
@@ -85,7 +85,7 @@ impl RenderedTimestamp for Timestamp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn to_ago(&self) -> SharedString {
|
fn to_ago(&self) -> SharedString {
|
||||||
let input_time = match Local.timestamp_opt(self.as_u64() as i64, 0) {
|
let input_time = match Local.timestamp_opt(self.as_secs() as i64, 0) {
|
||||||
chrono::LocalResult::Single(time) => time,
|
chrono::LocalResult::Single(time) => time,
|
||||||
_ => return SharedString::from("1m"),
|
_ => return SharedString::from("1m"),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ impl EventUtils for UnsignedEvent {
|
|||||||
fn all_pubkeys(&self) -> Vec<PublicKey> {
|
fn all_pubkeys(&self) -> Vec<PublicKey> {
|
||||||
let mut public_keys: Vec<PublicKey> = self.tags.public_keys().copied().collect();
|
let mut public_keys: Vec<PublicKey> = self.tags.public_keys().copied().collect();
|
||||||
public_keys.push(self.pubkey);
|
public_keys.push(self.pubkey);
|
||||||
|
public_keys.into_iter().unique().sorted().collect()
|
||||||
public_keys
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,68 @@
|
|||||||
pub mod debounced_delay;
|
use std::sync::OnceLock;
|
||||||
pub mod display;
|
|
||||||
pub mod event;
|
pub use constants::*;
|
||||||
pub mod nip05;
|
pub use debounced_delay::*;
|
||||||
pub mod nip96;
|
pub use display::*;
|
||||||
|
pub use event::*;
|
||||||
|
pub use nip05::*;
|
||||||
|
pub use nip96::*;
|
||||||
|
use nostr_sdk::prelude::*;
|
||||||
|
pub use paths::*;
|
||||||
|
|
||||||
|
mod constants;
|
||||||
|
mod debounced_delay;
|
||||||
|
mod display;
|
||||||
|
mod event;
|
||||||
|
mod nip05;
|
||||||
|
mod nip96;
|
||||||
|
mod paths;
|
||||||
|
|
||||||
|
static APP_NAME: OnceLock<String> = OnceLock::new();
|
||||||
|
static NIP65_RELAYS: OnceLock<Vec<(RelayUrl, Option<RelayMetadata>)>> = OnceLock::new();
|
||||||
|
static NIP17_RELAYS: OnceLock<Vec<RelayUrl>> = OnceLock::new();
|
||||||
|
|
||||||
|
/// Get the app name
|
||||||
|
pub fn app_name() -> &'static String {
|
||||||
|
APP_NAME.get_or_init(|| {
|
||||||
|
let devicename = whoami::devicename();
|
||||||
|
let platform = whoami::platform();
|
||||||
|
|
||||||
|
format!("{CLIENT_NAME} on {platform} ({devicename})")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Default NIP-65 Relays. Used for new account
|
||||||
|
pub fn default_nip65_relays() -> &'static Vec<(RelayUrl, Option<RelayMetadata>)> {
|
||||||
|
NIP65_RELAYS.get_or_init(|| {
|
||||||
|
vec![
|
||||||
|
(
|
||||||
|
RelayUrl::parse("wss://nostr.mom").unwrap(),
|
||||||
|
Some(RelayMetadata::Read),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
RelayUrl::parse("wss://nostr.bitcoiner.social").unwrap(),
|
||||||
|
Some(RelayMetadata::Read),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
RelayUrl::parse("wss://nos.lol").unwrap(),
|
||||||
|
Some(RelayMetadata::Write),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
RelayUrl::parse("wss://relay.snort.social").unwrap(),
|
||||||
|
Some(RelayMetadata::Write),
|
||||||
|
),
|
||||||
|
(RelayUrl::parse("wss://relay.primal.net").unwrap(), None),
|
||||||
|
(RelayUrl::parse("wss://relay.damus.io").unwrap(), None),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Default NIP-17 Relays. Used for new account
|
||||||
|
pub fn default_nip17_relays() -> &'static Vec<RelayUrl> {
|
||||||
|
NIP17_RELAYS.get_or_init(|| {
|
||||||
|
vec![
|
||||||
|
RelayUrl::parse("wss://nip17.com").unwrap(),
|
||||||
|
RelayUrl::parse("wss://auth.nostr1.com").unwrap(),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
+10
-8
@@ -14,7 +14,7 @@ product-name = "Coop"
|
|||||||
description = "Chat Freely, Stay Private on Nostr"
|
description = "Chat Freely, Stay Private on Nostr"
|
||||||
identifier = "su.reya.coop"
|
identifier = "su.reya.coop"
|
||||||
category = "SocialNetworking"
|
category = "SocialNetworking"
|
||||||
version = "0.2.11"
|
version = "0.3.0"
|
||||||
out-dir = "../../dist"
|
out-dir = "../../dist"
|
||||||
before-packaging-command = "cargo build --release"
|
before-packaging-command = "cargo build --release"
|
||||||
resources = ["Cargo.toml", "src"]
|
resources = ["Cargo.toml", "src"]
|
||||||
@@ -32,12 +32,17 @@ ui = { path = "../ui" }
|
|||||||
title_bar = { path = "../title_bar" }
|
title_bar = { path = "../title_bar" }
|
||||||
theme = { path = "../theme" }
|
theme = { path = "../theme" }
|
||||||
common = { path = "../common" }
|
common = { path = "../common" }
|
||||||
global = { path = "../global" }
|
state = { path = "../state" }
|
||||||
registry = { path = "../registry" }
|
key_store = { path = "../key_store" }
|
||||||
|
chat = { path = "../chat" }
|
||||||
|
chat_ui = { path = "../chat_ui" }
|
||||||
settings = { path = "../settings" }
|
settings = { path = "../settings" }
|
||||||
client_keys = { path = "../client_keys" }
|
|
||||||
auto_update = { path = "../auto_update" }
|
auto_update = { path = "../auto_update" }
|
||||||
signer_proxy = { path = "../signer_proxy" }
|
account = { path = "../account" }
|
||||||
|
encryption = { path = "../encryption" }
|
||||||
|
encryption_ui = { path = "../encryption_ui" }
|
||||||
|
person = { path = "../person" }
|
||||||
|
relay_auth = { path = "../relay_auth" }
|
||||||
|
|
||||||
rust-i18n.workspace = true
|
rust-i18n.workspace = true
|
||||||
i18n.workspace = true
|
i18n.workspace = true
|
||||||
@@ -47,19 +52,16 @@ reqwest_client.workspace = true
|
|||||||
|
|
||||||
nostr-connect.workspace = true
|
nostr-connect.workspace = true
|
||||||
nostr-sdk.workspace = true
|
nostr-sdk.workspace = true
|
||||||
nostr.workspace = true
|
|
||||||
|
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
itertools.workspace = true
|
itertools.workspace = true
|
||||||
dirs.workspace = true
|
|
||||||
log.workspace = true
|
log.workspace = true
|
||||||
smallvec.workspace = true
|
smallvec.workspace = true
|
||||||
smol.workspace = true
|
smol.workspace = true
|
||||||
futures.workspace = true
|
futures.workspace = true
|
||||||
oneshot.workspace = true
|
oneshot.workspace = true
|
||||||
flume.workspace = true
|
|
||||||
webbrowser.workspace = true
|
webbrowser.workspace = true
|
||||||
|
|
||||||
indexset = "0.12.3"
|
indexset = "0.12.3"
|
||||||
|
|||||||
@@ -1,15 +1,32 @@
|
|||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
|
||||||
use gpui::{actions, App};
|
use gpui::{actions, App};
|
||||||
|
use key_store::{KeyItem, KeyStore};
|
||||||
use nostr_connect::prelude::*;
|
use nostr_connect::prelude::*;
|
||||||
|
use state::NostrRegistry;
|
||||||
|
|
||||||
actions!(coop, [ReloadMetadata, DarkMode, Settings, Logout, Quit]);
|
// Sidebar actions
|
||||||
actions!(sidebar, [Reload, RelayStatus]);
|
actions!(sidebar, [Reload, RelayStatus]);
|
||||||
|
|
||||||
|
// User actions
|
||||||
|
actions!(
|
||||||
|
coop,
|
||||||
|
[
|
||||||
|
KeyringPopup,
|
||||||
|
DarkMode,
|
||||||
|
ViewProfile,
|
||||||
|
ViewRelays,
|
||||||
|
Settings,
|
||||||
|
Logout,
|
||||||
|
Quit
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct CoopAuthUrlHandler;
|
pub struct CoopAuthUrlHandler;
|
||||||
|
|
||||||
impl AuthUrlHandler for CoopAuthUrlHandler {
|
impl AuthUrlHandler for CoopAuthUrlHandler {
|
||||||
|
#[allow(mismatched_lifetime_syntaxes)]
|
||||||
fn on_auth_url(&self, auth_url: Url) -> BoxedFuture<Result<()>> {
|
fn on_auth_url(&self, auth_url: Url) -> BoxedFuture<Result<()>> {
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
log::info!("Received Auth URL: {auth_url}");
|
log::info!("Received Auth URL: {auth_url}");
|
||||||
@@ -43,6 +60,34 @@ pub fn load_embedded_fonts(cx: &App) {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn reset(cx: &mut App) {
|
||||||
|
let backend = KeyStore::global(cx).read(cx).backend();
|
||||||
|
let client = NostrRegistry::global(cx).read(cx).client();
|
||||||
|
|
||||||
|
cx.spawn(async move |cx| {
|
||||||
|
// Remove the signer
|
||||||
|
client.unset_signer().await;
|
||||||
|
|
||||||
|
// Delete user's credentials
|
||||||
|
backend
|
||||||
|
.delete_credentials(&KeyItem::User.to_string(), cx)
|
||||||
|
.await
|
||||||
|
.ok();
|
||||||
|
|
||||||
|
// Remove bunker's credentials if available
|
||||||
|
backend
|
||||||
|
.delete_credentials(&KeyItem::Bunker.to_string(), cx)
|
||||||
|
.await
|
||||||
|
.ok();
|
||||||
|
|
||||||
|
cx.update(|cx| {
|
||||||
|
cx.restart();
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
}
|
||||||
|
|
||||||
pub fn quit(_: &Quit, cx: &mut App) {
|
pub fn quit(_: &Quit, cx: &mut App) {
|
||||||
log::info!("Gracefully quitting the application . . .");
|
log::info!("Gracefully quitting the application . . .");
|
||||||
cx.quit();
|
cx.quit();
|
||||||
|
|||||||
+426
-1281
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,428 @@
|
|||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use anyhow::anyhow;
|
||||||
|
use common::BUNKER_TIMEOUT;
|
||||||
|
use gpui::prelude::FluentBuilder;
|
||||||
|
use gpui::{
|
||||||
|
div, relative, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle,
|
||||||
|
Focusable, IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Window,
|
||||||
|
};
|
||||||
|
use i18n::{shared_t, t};
|
||||||
|
use key_store::{KeyItem, KeyStore};
|
||||||
|
use nostr_connect::prelude::*;
|
||||||
|
use smallvec::{smallvec, SmallVec};
|
||||||
|
use state::NostrRegistry;
|
||||||
|
use theme::ActiveTheme;
|
||||||
|
use ui::button::{Button, ButtonVariants};
|
||||||
|
use ui::dock_area::panel::{Panel, PanelEvent};
|
||||||
|
use ui::input::{InputEvent, InputState, TextInput};
|
||||||
|
use ui::notification::Notification;
|
||||||
|
use ui::{v_flex, ContextModal, Disableable, StyledExt};
|
||||||
|
|
||||||
|
use crate::actions::CoopAuthUrlHandler;
|
||||||
|
|
||||||
|
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Login> {
|
||||||
|
cx.new(|cx| Login::new(window, cx))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Login {
|
||||||
|
key_input: Entity<InputState>,
|
||||||
|
pass_input: Entity<InputState>,
|
||||||
|
error: Entity<Option<SharedString>>,
|
||||||
|
countdown: Entity<Option<u64>>,
|
||||||
|
require_password: bool,
|
||||||
|
logging_in: bool,
|
||||||
|
|
||||||
|
/// Panel
|
||||||
|
name: SharedString,
|
||||||
|
focus_handle: FocusHandle,
|
||||||
|
|
||||||
|
/// Event subscriptions
|
||||||
|
_subscriptions: SmallVec<[Subscription; 1]>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Login {
|
||||||
|
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||||
|
let key_input = cx.new(|cx| InputState::new(window, cx));
|
||||||
|
let pass_input = cx.new(|cx| InputState::new(window, cx).masked(true));
|
||||||
|
|
||||||
|
let error = cx.new(|_| None);
|
||||||
|
let countdown = cx.new(|_| None);
|
||||||
|
|
||||||
|
let mut subscriptions = smallvec![];
|
||||||
|
|
||||||
|
subscriptions.push(
|
||||||
|
// Subscribe to key input events and process login when the user presses enter
|
||||||
|
cx.subscribe_in(&key_input, window, |this, input, event, window, cx| {
|
||||||
|
match event {
|
||||||
|
InputEvent::PressEnter { .. } => {
|
||||||
|
this.login(window, cx);
|
||||||
|
}
|
||||||
|
InputEvent::Change => {
|
||||||
|
if input.read(cx).value().starts_with("ncryptsec1") {
|
||||||
|
this.require_password = true;
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
key_input,
|
||||||
|
pass_input,
|
||||||
|
error,
|
||||||
|
countdown,
|
||||||
|
name: "Welcome Back".into(),
|
||||||
|
focus_handle: cx.focus_handle(),
|
||||||
|
logging_in: false,
|
||||||
|
require_password: false,
|
||||||
|
_subscriptions: subscriptions,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn login(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
if self.logging_in {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Prevent duplicate login requests
|
||||||
|
self.set_logging_in(true, cx);
|
||||||
|
|
||||||
|
let value = self.key_input.read(cx).value();
|
||||||
|
let password = self.pass_input.read(cx).value();
|
||||||
|
|
||||||
|
if value.starts_with("bunker://") {
|
||||||
|
self.login_with_bunker(&value, window, cx);
|
||||||
|
} else if value.starts_with("ncryptsec1") {
|
||||||
|
self.login_with_password(&value, &password, cx);
|
||||||
|
} else if value.starts_with("nsec1") {
|
||||||
|
if let Ok(secret) = SecretKey::parse(&value) {
|
||||||
|
let keys = Keys::new(secret);
|
||||||
|
self.login_with_keys(keys, cx);
|
||||||
|
} else {
|
||||||
|
self.set_error("Invalid", cx);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.set_error("Invalid", cx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn login_with_bunker(&mut self, content: &str, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
let Ok(uri) = NostrConnectUri::parse(content) else {
|
||||||
|
self.set_error(t!("login.bunker_invalid"), cx);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let app_keys = Keys::generate();
|
||||||
|
let timeout = Duration::from_secs(BUNKER_TIMEOUT);
|
||||||
|
let mut signer = NostrConnect::new(uri, app_keys.clone(), timeout, None).unwrap();
|
||||||
|
|
||||||
|
// Handle auth url with the default browser
|
||||||
|
signer.auth_url_handler(CoopAuthUrlHandler);
|
||||||
|
|
||||||
|
// Start countdown
|
||||||
|
cx.spawn_in(window, async move |this, cx| {
|
||||||
|
for i in (0..=BUNKER_TIMEOUT).rev() {
|
||||||
|
if i == 0 {
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.set_countdown(None, cx);
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
} else {
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.set_countdown(Some(i), cx);
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
cx.background_executor().timer(Duration::from_secs(1)).await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
|
||||||
|
// Handle connection
|
||||||
|
cx.spawn_in(window, async move |this, cx| {
|
||||||
|
let result = signer.bunker_uri().await;
|
||||||
|
|
||||||
|
this.update_in(cx, |this, window, cx| {
|
||||||
|
match result {
|
||||||
|
Ok(uri) => {
|
||||||
|
this.save_connection(&app_keys, &uri, window, cx);
|
||||||
|
this.connect(signer, cx);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
window.push_notification(Notification::error(e.to_string()), cx);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_connection(
|
||||||
|
&mut self,
|
||||||
|
keys: &Keys,
|
||||||
|
uri: &NostrConnectUri,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) {
|
||||||
|
let keystore = KeyStore::global(cx).read(cx).backend();
|
||||||
|
let username = keys.public_key().to_hex();
|
||||||
|
let secret = keys.secret_key().to_secret_bytes();
|
||||||
|
let mut clean_uri = uri.to_string();
|
||||||
|
|
||||||
|
// Clear the secret parameter in the URI if it exists
|
||||||
|
if let Some(s) = uri.secret() {
|
||||||
|
clean_uri = clean_uri.replace(s, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
cx.spawn_in(window, async move |this, cx| {
|
||||||
|
let user_url = KeyItem::User.to_string();
|
||||||
|
let bunker_url = KeyItem::Bunker.to_string();
|
||||||
|
let user_password = clean_uri.into_bytes();
|
||||||
|
|
||||||
|
// Write bunker uri to keyring for further connection
|
||||||
|
if let Err(e) = keystore
|
||||||
|
.write_credentials(&user_url, "bunker", &user_password, cx)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
this.update_in(cx, |_, window, cx| {
|
||||||
|
window.push_notification(e.to_string(), cx);
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write the app keys for further connection
|
||||||
|
if let Err(e) = keystore
|
||||||
|
.write_credentials(&bunker_url, &username, &secret, cx)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
this.update_in(cx, |_, window, cx| {
|
||||||
|
window.push_notification(e.to_string(), cx);
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn connect(&mut self, signer: NostrConnect, cx: &mut Context<Self>) {
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let client = nostr.read(cx).client();
|
||||||
|
|
||||||
|
cx.background_spawn(async move {
|
||||||
|
client.set_signer(signer).await;
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn login_with_password(&mut self, content: &str, pwd: &str, cx: &mut Context<Self>) {
|
||||||
|
if pwd.is_empty() {
|
||||||
|
self.set_error("Password is required", cx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Ok(enc) = EncryptedSecretKey::from_bech32(content) else {
|
||||||
|
self.set_error("Secret Key is invalid", cx);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let password = pwd.to_owned();
|
||||||
|
|
||||||
|
// Decrypt in the background to ensure it doesn't block the UI
|
||||||
|
let task = cx.background_spawn(async move {
|
||||||
|
if let Ok(content) = enc.decrypt(&password) {
|
||||||
|
Ok(Keys::new(content))
|
||||||
|
} else {
|
||||||
|
Err(anyhow!("Invalid password"))
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
cx.spawn(async move |this, cx| {
|
||||||
|
let result = task.await;
|
||||||
|
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
match result {
|
||||||
|
Ok(keys) => {
|
||||||
|
this.login_with_keys(keys, cx);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
this.set_error(e.to_string(), cx);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn login_with_keys(&mut self, keys: Keys, cx: &mut Context<Self>) {
|
||||||
|
let keystore = KeyStore::global(cx).read(cx).backend();
|
||||||
|
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let client = nostr.read(cx).client();
|
||||||
|
|
||||||
|
let username = keys.public_key().to_hex();
|
||||||
|
let secret = keys.secret_key().to_secret_hex().into_bytes();
|
||||||
|
|
||||||
|
cx.spawn(async move |this, cx| {
|
||||||
|
let bunker_url = KeyItem::User.to_string();
|
||||||
|
|
||||||
|
// Write the app keys for further connection
|
||||||
|
if let Err(e) = keystore
|
||||||
|
.write_credentials(&bunker_url, &username, &secret, cx)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.set_error(e.to_string(), cx);
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the signer
|
||||||
|
cx.background_spawn(async move {
|
||||||
|
client.set_signer(keys).await;
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_error<S>(&mut self, message: S, cx: &mut Context<Self>)
|
||||||
|
where
|
||||||
|
S: Into<SharedString>,
|
||||||
|
{
|
||||||
|
// Reset the log in state
|
||||||
|
self.set_logging_in(false, cx);
|
||||||
|
|
||||||
|
// Reset the countdown
|
||||||
|
self.set_countdown(None, cx);
|
||||||
|
|
||||||
|
// Update error message
|
||||||
|
self.error.update(cx, |this, cx| {
|
||||||
|
*this = Some(message.into());
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear the error message after 3 secs
|
||||||
|
cx.spawn(async move |this, cx| {
|
||||||
|
cx.background_executor().timer(Duration::from_secs(3)).await;
|
||||||
|
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.error.update(cx, |this, cx| {
|
||||||
|
*this = None;
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_logging_in(&mut self, status: bool, cx: &mut Context<Self>) {
|
||||||
|
self.logging_in = status;
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_countdown(&mut self, i: Option<u64>, cx: &mut Context<Self>) {
|
||||||
|
self.countdown.update(cx, |this, cx| {
|
||||||
|
*this = i;
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Panel for Login {
|
||||||
|
fn panel_id(&self) -> SharedString {
|
||||||
|
self.name.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn title(&self, _cx: &App) -> AnyElement {
|
||||||
|
self.name.clone().into_any_element()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EventEmitter<PanelEvent> for Login {}
|
||||||
|
|
||||||
|
impl Focusable for Login {
|
||||||
|
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
|
||||||
|
self.focus_handle.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for Login {
|
||||||
|
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
v_flex()
|
||||||
|
.relative()
|
||||||
|
.size_full()
|
||||||
|
.items_center()
|
||||||
|
.justify_center()
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.w_96()
|
||||||
|
.gap_10()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_center()
|
||||||
|
.text_xl()
|
||||||
|
.font_semibold()
|
||||||
|
.line_height(relative(1.3))
|
||||||
|
.child(SharedString::from("Continue with Private Key or Bunker")),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.gap_3()
|
||||||
|
.text_sm()
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.gap_1()
|
||||||
|
.text_sm()
|
||||||
|
.text_color(cx.theme().text_muted)
|
||||||
|
.child("nsec or bunker://")
|
||||||
|
.child(TextInput::new(&self.key_input)),
|
||||||
|
)
|
||||||
|
.when(self.require_password, |this| {
|
||||||
|
this.child(
|
||||||
|
v_flex()
|
||||||
|
.gap_1()
|
||||||
|
.text_sm()
|
||||||
|
.text_color(cx.theme().text_muted)
|
||||||
|
.child("Password:")
|
||||||
|
.child(TextInput::new(&self.pass_input)),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.child(
|
||||||
|
Button::new("login")
|
||||||
|
.label(t!("common.continue"))
|
||||||
|
.primary()
|
||||||
|
.loading(self.logging_in)
|
||||||
|
.disabled(self.logging_in)
|
||||||
|
.on_click(cx.listener(move |this, _, window, cx| {
|
||||||
|
this.login(window, cx);
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.when_some(self.countdown.read(cx).as_ref(), |this, i| {
|
||||||
|
this.child(
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
|
.text_center()
|
||||||
|
.text_color(cx.theme().text_muted)
|
||||||
|
.child(shared_t!("login.approve_message", i = i)),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.when_some(self.error.read(cx).as_ref(), |this, error| {
|
||||||
|
this.child(
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
|
.text_center()
|
||||||
|
.text_color(cx.theme().danger_foreground)
|
||||||
|
.child(error.clone()),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+28
-16
@@ -1,8 +1,7 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use assets::Assets;
|
use assets::Assets;
|
||||||
use global::constants::{APP_ID, APP_NAME};
|
use common::{APP_ID, CLIENT_NAME};
|
||||||
use global::{app_state, nostr_client};
|
|
||||||
use gpui::{
|
use gpui::{
|
||||||
point, px, size, AppContext, Application, Bounds, KeyBinding, Menu, MenuItem, SharedString,
|
point, px, size, AppContext, Application, Bounds, KeyBinding, Menu, MenuItem, SharedString,
|
||||||
TitlebarOptions, WindowBackgroundAppearance, WindowBounds, WindowDecorations, WindowKind,
|
TitlebarOptions, WindowBackgroundAppearance, WindowBounds, WindowDecorations, WindowKind,
|
||||||
@@ -12,9 +11,13 @@ use ui::Root;
|
|||||||
|
|
||||||
use crate::actions::{load_embedded_fonts, quit, Quit};
|
use crate::actions::{load_embedded_fonts, quit, Quit};
|
||||||
|
|
||||||
pub(crate) mod actions;
|
mod actions;
|
||||||
pub(crate) mod chatspace;
|
mod chatspace;
|
||||||
pub(crate) mod views;
|
mod login;
|
||||||
|
mod new_identity;
|
||||||
|
mod sidebar;
|
||||||
|
mod user;
|
||||||
|
mod views;
|
||||||
|
|
||||||
i18n::init!();
|
i18n::init!();
|
||||||
|
|
||||||
@@ -22,12 +25,6 @@ fn main() {
|
|||||||
// Initialize logging
|
// Initialize logging
|
||||||
tracing_subscriber::fmt::init();
|
tracing_subscriber::fmt::init();
|
||||||
|
|
||||||
// Initialize the Nostr client
|
|
||||||
let _client = nostr_client();
|
|
||||||
|
|
||||||
// Initialize the coop simple storage
|
|
||||||
let _app_state = app_state();
|
|
||||||
|
|
||||||
// Initialize the Application
|
// Initialize the Application
|
||||||
let app = Application::new()
|
let app = Application::new()
|
||||||
.with_assets(Assets)
|
.with_assets(Assets)
|
||||||
@@ -66,7 +63,7 @@ fn main() {
|
|||||||
kind: WindowKind::Normal,
|
kind: WindowKind::Normal,
|
||||||
app_id: Some(APP_ID.to_owned()),
|
app_id: Some(APP_ID.to_owned()),
|
||||||
titlebar: Some(TitlebarOptions {
|
titlebar: Some(TitlebarOptions {
|
||||||
title: Some(SharedString::new_static(APP_NAME)),
|
title: Some(SharedString::new_static(CLIENT_NAME)),
|
||||||
traffic_light_position: Some(point(px(9.0), px(9.0))),
|
traffic_light_position: Some(point(px(9.0), px(9.0))),
|
||||||
appears_transparent: true,
|
appears_transparent: true,
|
||||||
}),
|
}),
|
||||||
@@ -86,15 +83,30 @@ fn main() {
|
|||||||
// Initialize components
|
// Initialize components
|
||||||
ui::init(cx);
|
ui::init(cx);
|
||||||
|
|
||||||
// Initialize client keys
|
// Initialize backend for keys storage
|
||||||
client_keys::init(cx);
|
key_store::init(cx);
|
||||||
|
|
||||||
// Initialize app registry
|
// Initialize the nostr client
|
||||||
registry::init(cx);
|
state::init(cx);
|
||||||
|
|
||||||
|
// Initialize person registry
|
||||||
|
person::init(cx);
|
||||||
|
|
||||||
// Initialize settings
|
// Initialize settings
|
||||||
settings::init(cx);
|
settings::init(cx);
|
||||||
|
|
||||||
|
// Initialize account state
|
||||||
|
account::init(cx);
|
||||||
|
|
||||||
|
// Initialize encryption state
|
||||||
|
encryption::init(cx);
|
||||||
|
|
||||||
|
// Initialize app registry
|
||||||
|
chat::init(cx);
|
||||||
|
|
||||||
|
// Initialize relay auth registry
|
||||||
|
relay_auth::init(window, cx);
|
||||||
|
|
||||||
// Initialize auto update
|
// Initialize auto update
|
||||||
auto_update::init(cx);
|
auto_update::init(cx);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use anyhow::{anyhow, Error};
|
||||||
|
use common::home_dir;
|
||||||
|
use gpui::{
|
||||||
|
div, App, AppContext, ClipboardItem, Context, Entity, Flatten, IntoElement, ParentElement,
|
||||||
|
Render, SharedString, Styled, Task, Window,
|
||||||
|
};
|
||||||
|
use nostr_sdk::prelude::*;
|
||||||
|
use smallvec::{smallvec, SmallVec};
|
||||||
|
use theme::ActiveTheme;
|
||||||
|
use ui::button::{Button, ButtonVariants};
|
||||||
|
use ui::input::{InputState, TextInput};
|
||||||
|
use ui::{divider, h_flex, v_flex, Disableable, IconName, Sizable, StyledExt};
|
||||||
|
|
||||||
|
pub fn init(keys: &Keys, window: &mut Window, cx: &mut App) -> Entity<Backup> {
|
||||||
|
cx.new(|cx| Backup::new(keys, window, cx))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Backup {
|
||||||
|
pubkey_input: Entity<InputState>,
|
||||||
|
secret_input: Entity<InputState>,
|
||||||
|
error: Option<SharedString>,
|
||||||
|
copied: bool,
|
||||||
|
|
||||||
|
// Async operations
|
||||||
|
_tasks: SmallVec<[Task<()>; 1]>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Backup {
|
||||||
|
pub fn new(keys: &Keys, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||||
|
let Ok(npub) = keys.public_key.to_bech32();
|
||||||
|
let Ok(nsec) = keys.secret_key().to_bech32();
|
||||||
|
|
||||||
|
let pubkey_input = cx.new(|cx| {
|
||||||
|
InputState::new(window, cx)
|
||||||
|
.disabled(true)
|
||||||
|
.default_value(npub)
|
||||||
|
});
|
||||||
|
|
||||||
|
let secret_input = cx.new(|cx| {
|
||||||
|
InputState::new(window, cx)
|
||||||
|
.disabled(true)
|
||||||
|
.default_value(nsec)
|
||||||
|
});
|
||||||
|
|
||||||
|
Self {
|
||||||
|
pubkey_input,
|
||||||
|
secret_input,
|
||||||
|
error: None,
|
||||||
|
copied: false,
|
||||||
|
_tasks: smallvec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn backup(&self, window: &Window, cx: &Context<Self>) -> Task<Result<(), Error>> {
|
||||||
|
let dir = home_dir();
|
||||||
|
let path = cx.prompt_for_new_path(dir, Some("My Nostr Account"));
|
||||||
|
let nsec = self.secret_input.read(cx).value().to_string();
|
||||||
|
|
||||||
|
cx.spawn_in(window, async move |this, cx| {
|
||||||
|
match Flatten::flatten(path.await.map_err(|e| e.into())) {
|
||||||
|
Ok(Ok(Some(path))) => {
|
||||||
|
if let Err(e) = smol::fs::write(&path, nsec).await {
|
||||||
|
this.update_in(cx, |this, window, cx| {
|
||||||
|
this.set_error(e.to_string(), window, cx);
|
||||||
|
})
|
||||||
|
.expect("Entity has been released");
|
||||||
|
} else {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
log::error!("Failed to save backup keys");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Err(anyhow!("Failed to backup keys"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn copy(&mut self, value: impl Into<String>, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
let item = ClipboardItem::new_string(value.into());
|
||||||
|
cx.write_to_clipboard(item);
|
||||||
|
|
||||||
|
self.set_copied(true, window, cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_copied(&mut self, status: bool, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
self.copied = status;
|
||||||
|
cx.notify();
|
||||||
|
|
||||||
|
// Reset the copied state after a delay
|
||||||
|
if status {
|
||||||
|
self._tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||||
|
cx.background_executor().timer(Duration::from_secs(2)).await;
|
||||||
|
|
||||||
|
this.update_in(cx, |this, window, cx| {
|
||||||
|
this.set_copied(false, window, cx);
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_error<E>(&mut self, error: E, window: &mut Window, cx: &mut Context<Self>)
|
||||||
|
where
|
||||||
|
E: Into<SharedString>,
|
||||||
|
{
|
||||||
|
self.error = Some(error.into());
|
||||||
|
cx.notify();
|
||||||
|
|
||||||
|
// Clear the error message after a delay
|
||||||
|
self._tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||||
|
cx.background_executor().timer(Duration::from_secs(2)).await;
|
||||||
|
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.error = None;
|
||||||
|
cx.notify();
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for Backup {
|
||||||
|
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
const DESCRIPTION: &str = "In Nostr, your account is defined by a KEY PAIR. These keys are used to sign your messages and identify you.";
|
||||||
|
const WARN: &str = "You must keep the Secret Key in a safe place. If you lose it, you will lose access to your account.";
|
||||||
|
const PK: &str = "Public Key is the address that others will use to find you.";
|
||||||
|
const SK: &str = "Secret Key provides access to your account.";
|
||||||
|
|
||||||
|
v_flex()
|
||||||
|
.gap_2()
|
||||||
|
.text_sm()
|
||||||
|
.child(SharedString::from(DESCRIPTION))
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.gap_1()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.font_semibold()
|
||||||
|
.child(SharedString::from("Public Key:")),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.gap_1()
|
||||||
|
.child(TextInput::new(&self.pubkey_input).small())
|
||||||
|
.child(
|
||||||
|
Button::new("copy-pubkey")
|
||||||
|
.icon({
|
||||||
|
if self.copied {
|
||||||
|
IconName::CheckCircleFill
|
||||||
|
} else {
|
||||||
|
IconName::Copy
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.ghost_alt()
|
||||||
|
.disabled(self.copied)
|
||||||
|
.on_click(cx.listener(move |this, _e, window, cx| {
|
||||||
|
this.copy(this.pubkey_input.read(cx).value(), window, cx);
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(cx.theme().text_muted)
|
||||||
|
.child(SharedString::from(PK)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(divider(cx))
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.gap_1()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.font_semibold()
|
||||||
|
.child(SharedString::from("Secret Key:")),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.gap_1()
|
||||||
|
.child(TextInput::new(&self.secret_input).small())
|
||||||
|
.child(
|
||||||
|
Button::new("copy-secret")
|
||||||
|
.icon({
|
||||||
|
if self.copied {
|
||||||
|
IconName::CheckCircleFill
|
||||||
|
} else {
|
||||||
|
IconName::Copy
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.ghost_alt()
|
||||||
|
.disabled(self.copied)
|
||||||
|
.on_click(cx.listener(move |this, _e, window, cx| {
|
||||||
|
this.copy(this.secret_input.read(cx).value(), window, cx);
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(cx.theme().text_muted)
|
||||||
|
.child(SharedString::from(SK)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(divider(cx))
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(cx.theme().danger_foreground)
|
||||||
|
.child(SharedString::from(WARN)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,353 @@
|
|||||||
|
use anyhow::{anyhow, Error};
|
||||||
|
use common::{default_nip17_relays, default_nip65_relays, nip96_upload, BOOTSTRAP_RELAYS};
|
||||||
|
use gpui::{
|
||||||
|
rems, AnyElement, App, AppContext, Context, Entity, EventEmitter, Flatten, FocusHandle,
|
||||||
|
Focusable, IntoElement, ParentElement, PathPromptOptions, Render, SharedString, Styled, Task,
|
||||||
|
Window,
|
||||||
|
};
|
||||||
|
use gpui_tokio::Tokio;
|
||||||
|
use i18n::{shared_t, t};
|
||||||
|
use key_store::{KeyItem, KeyStore};
|
||||||
|
use nostr_sdk::prelude::*;
|
||||||
|
use settings::AppSettings;
|
||||||
|
use smol::fs;
|
||||||
|
use state::NostrRegistry;
|
||||||
|
use ui::avatar::Avatar;
|
||||||
|
use ui::button::{Button, ButtonVariants};
|
||||||
|
use ui::dock_area::panel::{Panel, PanelEvent};
|
||||||
|
use ui::input::{InputState, TextInput};
|
||||||
|
use ui::modal::ModalButtonProps;
|
||||||
|
use ui::{divider, v_flex, ContextModal, Disableable, IconName, Sizable};
|
||||||
|
|
||||||
|
mod backup;
|
||||||
|
|
||||||
|
pub fn init(window: &mut Window, cx: &mut App) -> Entity<NewAccount> {
|
||||||
|
cx.new(|cx| NewAccount::new(window, cx))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct NewAccount {
|
||||||
|
name_input: Entity<InputState>,
|
||||||
|
avatar_input: Entity<InputState>,
|
||||||
|
temp_keys: Entity<Keys>,
|
||||||
|
uploading: bool,
|
||||||
|
submitting: bool,
|
||||||
|
// Panel
|
||||||
|
name: SharedString,
|
||||||
|
focus_handle: FocusHandle,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NewAccount {
|
||||||
|
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||||
|
let temp_keys = cx.new(|_| Keys::generate());
|
||||||
|
let name_input = cx.new(|cx| InputState::new(window, cx).placeholder("Alice"));
|
||||||
|
let avatar_input = cx.new(|cx| InputState::new(window, cx));
|
||||||
|
|
||||||
|
Self {
|
||||||
|
name_input,
|
||||||
|
avatar_input,
|
||||||
|
temp_keys,
|
||||||
|
uploading: false,
|
||||||
|
submitting: false,
|
||||||
|
name: "Create a new identity".into(),
|
||||||
|
focus_handle: cx.focus_handle(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
self.submitting(true, cx);
|
||||||
|
|
||||||
|
let keys = self.temp_keys.read(cx).clone();
|
||||||
|
let view = backup::init(&keys, window, cx);
|
||||||
|
let weak_view = view.downgrade();
|
||||||
|
let current_view = cx.entity().downgrade();
|
||||||
|
|
||||||
|
window.open_modal(cx, move |modal, _window, _cx| {
|
||||||
|
let weak_view = weak_view.clone();
|
||||||
|
let current_view = current_view.clone();
|
||||||
|
|
||||||
|
modal
|
||||||
|
.alert()
|
||||||
|
.title(shared_t!("new_account.backup_label"))
|
||||||
|
.child(view.clone())
|
||||||
|
.button_props(
|
||||||
|
ModalButtonProps::default().ok_text(t!("new_account.backup_download")),
|
||||||
|
)
|
||||||
|
.on_ok(move |_, window, cx| {
|
||||||
|
weak_view
|
||||||
|
.update(cx, |this, cx| {
|
||||||
|
let view = current_view.clone();
|
||||||
|
let task = this.backup(window, cx);
|
||||||
|
|
||||||
|
cx.spawn_in(window, async move |_this, cx| {
|
||||||
|
let result = task.await;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(_) => {
|
||||||
|
view.update_in(cx, |this, window, cx| {
|
||||||
|
this.set_signer(window, cx);
|
||||||
|
})
|
||||||
|
.expect("Entity has been released");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Failed to backup: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
// true to close the modal
|
||||||
|
false
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_signer(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
let keystore = KeyStore::global(cx).read(cx).backend();
|
||||||
|
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let client = nostr.read(cx).client();
|
||||||
|
|
||||||
|
let keys = self.temp_keys.read(cx).clone();
|
||||||
|
let username = keys.public_key().to_hex();
|
||||||
|
let secret = keys.secret_key().to_secret_hex().into_bytes();
|
||||||
|
|
||||||
|
let avatar = self.avatar_input.read(cx).value().to_string();
|
||||||
|
let name = self.name_input.read(cx).value().to_string();
|
||||||
|
let mut metadata = Metadata::new().display_name(name.clone()).name(name);
|
||||||
|
|
||||||
|
if let Ok(url) = Url::parse(&avatar) {
|
||||||
|
metadata = metadata.picture(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Close all modals if available
|
||||||
|
window.close_all_modals(cx);
|
||||||
|
|
||||||
|
// Set the client's signer with the current keys
|
||||||
|
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||||
|
let signer = keys.clone();
|
||||||
|
let nip65_relays = default_nip65_relays();
|
||||||
|
let nip17_relays = default_nip17_relays();
|
||||||
|
|
||||||
|
// Construct a NIP-65 event
|
||||||
|
let event = EventBuilder::new(Kind::RelayList, "")
|
||||||
|
.tags(
|
||||||
|
nip65_relays
|
||||||
|
.iter()
|
||||||
|
.cloned()
|
||||||
|
.map(|(url, metadata)| Tag::relay_metadata(url, metadata)),
|
||||||
|
)
|
||||||
|
.sign(&signer)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Set NIP-65 relays
|
||||||
|
client.send_event_to(BOOTSTRAP_RELAYS, &event).await?;
|
||||||
|
|
||||||
|
// Extract only write relays
|
||||||
|
let write_relays: Vec<RelayUrl> = nip65_relays
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(url, metadata)| {
|
||||||
|
if metadata.is_none() || metadata == &Some(RelayMetadata::Write) {
|
||||||
|
Some(url.to_owned())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Ensure relays are connected
|
||||||
|
for url in write_relays.iter() {
|
||||||
|
client.add_relay(url).await?;
|
||||||
|
client.connect_relay(url).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct a NIP-17 event
|
||||||
|
let event = EventBuilder::new(Kind::InboxRelays, "")
|
||||||
|
.tags(nip17_relays.iter().cloned().map(Tag::relay))
|
||||||
|
.sign(&signer)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Set NIP-17 relays
|
||||||
|
client.send_event_to(&write_relays, &event).await?;
|
||||||
|
|
||||||
|
// Construct a metadata event
|
||||||
|
let event = EventBuilder::metadata(&metadata).sign(&signer).await?;
|
||||||
|
|
||||||
|
// Send metadata event to both write relays and bootstrap relays
|
||||||
|
client.send_event_to(&write_relays, &event).await?;
|
||||||
|
client.send_event_to(BOOTSTRAP_RELAYS, &event).await?;
|
||||||
|
|
||||||
|
// Update the client's signer with the current keys
|
||||||
|
client.set_signer(keys).await;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
|
|
||||||
|
cx.spawn_in(window, async move |this, cx| {
|
||||||
|
let url = KeyItem::User.to_string();
|
||||||
|
|
||||||
|
// Write the app keys for further connection
|
||||||
|
keystore
|
||||||
|
.write_credentials(&url, &username, &secret, cx)
|
||||||
|
.await
|
||||||
|
.ok();
|
||||||
|
|
||||||
|
if let Err(e) = task.await {
|
||||||
|
this.update_in(cx, |this, window, cx| {
|
||||||
|
this.submitting(false, cx);
|
||||||
|
window.push_notification(e.to_string(), cx);
|
||||||
|
})
|
||||||
|
.expect("Entity has been released");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn upload(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
self.uploading(true, cx);
|
||||||
|
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let client = nostr.read(cx).client();
|
||||||
|
|
||||||
|
// Get the user's configured NIP96 server
|
||||||
|
let nip96_server = AppSettings::get_media_server(cx);
|
||||||
|
|
||||||
|
// Open native file dialog
|
||||||
|
let paths = cx.prompt_for_paths(PathPromptOptions {
|
||||||
|
files: true,
|
||||||
|
directories: false,
|
||||||
|
multiple: false,
|
||||||
|
prompt: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
let task = Tokio::spawn(cx, async move {
|
||||||
|
match Flatten::flatten(paths.await.map_err(|e| e.into())) {
|
||||||
|
Ok(Some(mut paths)) => {
|
||||||
|
if let Some(path) = paths.pop() {
|
||||||
|
let file = fs::read(path).await?;
|
||||||
|
let url = nip96_upload(&client, &nip96_server, file).await?;
|
||||||
|
|
||||||
|
Ok(url)
|
||||||
|
} else {
|
||||||
|
Err(anyhow!("Path not found"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(None) => Err(anyhow!("User cancelled")),
|
||||||
|
Err(e) => Err(anyhow!("File dialog error: {e}")),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
cx.spawn_in(window, async move |this, cx| {
|
||||||
|
let result = Flatten::flatten(task.await.map_err(|e| e.into()));
|
||||||
|
|
||||||
|
this.update_in(cx, |this, window, cx| {
|
||||||
|
match result {
|
||||||
|
Ok(Ok(url)) => {
|
||||||
|
this.avatar_input.update(cx, |this, cx| {
|
||||||
|
this.set_value(url.to_string(), window, cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
window.push_notification(e.to_string(), cx);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("Failed to upload avatar: {e}");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this.uploading(false, cx);
|
||||||
|
})
|
||||||
|
.expect("Entity has been released");
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn submitting(&mut self, status: bool, cx: &mut Context<Self>) {
|
||||||
|
self.submitting = status;
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn uploading(&mut self, status: bool, cx: &mut Context<Self>) {
|
||||||
|
self.uploading = status;
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Panel for NewAccount {
|
||||||
|
fn panel_id(&self) -> SharedString {
|
||||||
|
self.name.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn title(&self, _cx: &App) -> AnyElement {
|
||||||
|
self.name.clone().into_any_element()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EventEmitter<PanelEvent> for NewAccount {}
|
||||||
|
|
||||||
|
impl Focusable for NewAccount {
|
||||||
|
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
|
||||||
|
self.focus_handle.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for NewAccount {
|
||||||
|
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
let avatar = self.avatar_input.read(cx).value();
|
||||||
|
|
||||||
|
v_flex()
|
||||||
|
.size_full()
|
||||||
|
.relative()
|
||||||
|
.items_center()
|
||||||
|
.justify_center()
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.w_96()
|
||||||
|
.gap_2()
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.h_40()
|
||||||
|
.w_full()
|
||||||
|
.items_center()
|
||||||
|
.justify_center()
|
||||||
|
.gap_4()
|
||||||
|
.child(Avatar::new(avatar).size(rems(4.25)))
|
||||||
|
.child(
|
||||||
|
Button::new("upload")
|
||||||
|
.icon(IconName::PlusCircleFill)
|
||||||
|
.label("Add an avatar")
|
||||||
|
.xsmall()
|
||||||
|
.ghost()
|
||||||
|
.rounded()
|
||||||
|
.disabled(self.uploading)
|
||||||
|
//.loading(self.uploading)
|
||||||
|
.on_click(cx.listener(move |this, _, window, cx| {
|
||||||
|
this.upload(window, cx);
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.gap_1()
|
||||||
|
.text_sm()
|
||||||
|
.child(shared_t!("new_account.name"))
|
||||||
|
.child(
|
||||||
|
TextInput::new(&self.name_input)
|
||||||
|
.disabled(self.submitting)
|
||||||
|
.small(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(divider(cx))
|
||||||
|
.child(
|
||||||
|
Button::new("submit")
|
||||||
|
.label(t!("common.continue"))
|
||||||
|
.primary()
|
||||||
|
.loading(self.submitting)
|
||||||
|
.disabled(self.submitting || self.uploading)
|
||||||
|
.on_click(cx.listener(move |this, _, window, cx| {
|
||||||
|
this.create(window, cx);
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,17 +1,16 @@
|
|||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use chat::{ChatRegistry, RoomKind};
|
||||||
|
use chat_ui::{CopyPublicKey, OpenPublicKey};
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
div, rems, App, ClickEvent, InteractiveElement, IntoElement, ParentElement as _, RenderOnce,
|
div, rems, App, ClickEvent, InteractiveElement, IntoElement, ParentElement as _, RenderOnce,
|
||||||
SharedString, SharedUri, StatefulInteractiveElement, Styled, Window,
|
SharedString, StatefulInteractiveElement, Styled, Window,
|
||||||
};
|
};
|
||||||
use i18n::t;
|
use i18n::t;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use registry::room::RoomKind;
|
|
||||||
use registry::Registry;
|
|
||||||
use settings::AppSettings;
|
use settings::AppSettings;
|
||||||
use theme::ActiveTheme;
|
use theme::ActiveTheme;
|
||||||
use ui::actions::{CopyPublicKey, OpenPublicKey};
|
|
||||||
use ui::avatar::Avatar;
|
use ui::avatar::Avatar;
|
||||||
use ui::context_menu::ContextMenuExt;
|
use ui::context_menu::ContextMenuExt;
|
||||||
use ui::modal::ModalButtonProps;
|
use ui::modal::ModalButtonProps;
|
||||||
@@ -26,7 +25,7 @@ pub struct RoomListItem {
|
|||||||
room_id: Option<u64>,
|
room_id: Option<u64>,
|
||||||
public_key: Option<PublicKey>,
|
public_key: Option<PublicKey>,
|
||||||
name: Option<SharedString>,
|
name: Option<SharedString>,
|
||||||
avatar: Option<SharedUri>,
|
avatar: Option<SharedString>,
|
||||||
created_at: Option<SharedString>,
|
created_at: Option<SharedString>,
|
||||||
kind: Option<RoomKind>,
|
kind: Option<RoomKind>,
|
||||||
#[allow(clippy::type_complexity)]
|
#[allow(clippy::type_complexity)]
|
||||||
@@ -62,7 +61,7 @@ impl RoomListItem {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn avatar(mut self, avatar: impl Into<SharedUri>) -> Self {
|
pub fn avatar(mut self, avatar: impl Into<SharedString>) -> Self {
|
||||||
self.avatar = Some(avatar.into());
|
self.avatar = Some(avatar.into());
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
@@ -187,7 +186,7 @@ impl RenderOnce for RoomListItem {
|
|||||||
.ok_text(t!("screening.response")),
|
.ok_text(t!("screening.response")),
|
||||||
)
|
)
|
||||||
.on_cancel(move |_event, _window, cx| {
|
.on_cancel(move |_event, _window, cx| {
|
||||||
Registry::global(cx).update(cx, |this, cx| {
|
ChatRegistry::global(cx).update(cx, |this, cx| {
|
||||||
this.close_room(room_id, cx);
|
this.close_room(room_id, cx);
|
||||||
});
|
});
|
||||||
// false to prevent closing the modal
|
// false to prevent closing the modal
|
||||||
@@ -1,12 +1,10 @@
|
|||||||
use std::collections::BTreeSet;
|
|
||||||
use std::ops::Range;
|
use std::ops::Range;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use account::Account;
|
||||||
use anyhow::{anyhow, Error};
|
use anyhow::{anyhow, Error};
|
||||||
use common::debounced_delay::DebouncedDelay;
|
use chat::{ChatEvent, ChatRegistry, Room, RoomKind};
|
||||||
use common::display::{RenderedTimestamp, TextUtils};
|
use common::{DebouncedDelay, RenderedTimestamp, TextUtils, BOOTSTRAP_RELAYS, SEARCH_RELAYS};
|
||||||
use global::constants::{BOOTSTRAP_RELAYS, SEARCH_RELAYS};
|
|
||||||
use global::{app_state, nostr_client, UnwrappingStatus};
|
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
deferred, div, relative, uniform_list, AnyElement, App, AppContext, Context, Entity,
|
deferred, div, relative, uniform_list, AnyElement, App, AppContext, Context, Entity,
|
||||||
@@ -15,18 +13,16 @@ use gpui::{
|
|||||||
};
|
};
|
||||||
use gpui_tokio::Tokio;
|
use gpui_tokio::Tokio;
|
||||||
use i18n::{shared_t, t};
|
use i18n::{shared_t, t};
|
||||||
use itertools::Itertools;
|
|
||||||
use list_item::RoomListItem;
|
use list_item::RoomListItem;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use registry::room::{Room, RoomKind};
|
|
||||||
use registry::{Registry, RegistryEvent};
|
|
||||||
use settings::AppSettings;
|
use settings::AppSettings;
|
||||||
use smallvec::{smallvec, SmallVec};
|
use smallvec::{smallvec, SmallVec};
|
||||||
|
use state::{NostrRegistry, GIFTWRAP_SUBSCRIPTION};
|
||||||
use theme::ActiveTheme;
|
use theme::ActiveTheme;
|
||||||
use ui::button::{Button, ButtonVariants};
|
use ui::button::{Button, ButtonVariants};
|
||||||
use ui::dock_area::panel::{Panel, PanelEvent};
|
use ui::dock_area::panel::{Panel, PanelEvent};
|
||||||
use ui::input::{InputEvent, InputState, TextInput};
|
use ui::input::{InputEvent, InputState, TextInput};
|
||||||
use ui::popup_menu::{PopupMenu, PopupMenuExt};
|
use ui::popup_menu::PopupMenuExt;
|
||||||
use ui::{h_flex, v_flex, ContextModal, Icon, IconName, Selectable, Sizable, StyledExt};
|
use ui::{h_flex, v_flex, ContextModal, Icon, IconName, Selectable, Sizable, StyledExt};
|
||||||
|
|
||||||
use crate::actions::{RelayStatus, Reload};
|
use crate::actions::{RelayStatus, Reload};
|
||||||
@@ -34,47 +30,48 @@ use crate::actions::{RelayStatus, Reload};
|
|||||||
mod list_item;
|
mod list_item;
|
||||||
|
|
||||||
const FIND_DELAY: u64 = 600;
|
const FIND_DELAY: u64 = 600;
|
||||||
const FIND_LIMIT: usize = 10;
|
const FIND_LIMIT: usize = 20;
|
||||||
|
|
||||||
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)
|
cx.new(|cx| Sidebar::new(window, cx))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct Sidebar {
|
pub struct Sidebar {
|
||||||
name: SharedString,
|
name: SharedString,
|
||||||
// Search
|
|
||||||
|
/// Focus handle for the sidebar
|
||||||
|
focus_handle: FocusHandle,
|
||||||
|
|
||||||
|
/// Image cache
|
||||||
|
image_cache: Entity<RetainAllImageCache>,
|
||||||
|
|
||||||
|
/// Search results
|
||||||
|
search_results: Entity<Option<Vec<Entity<Room>>>>,
|
||||||
|
|
||||||
|
/// Async search operation
|
||||||
|
search_task: Option<Task<()>>,
|
||||||
|
|
||||||
find_input: Entity<InputState>,
|
find_input: Entity<InputState>,
|
||||||
find_debouncer: DebouncedDelay<Self>,
|
find_debouncer: DebouncedDelay<Self>,
|
||||||
finding: bool,
|
finding: bool,
|
||||||
cancel_handle: Entity<Option<smol::channel::Sender<()>>>,
|
|
||||||
local_result: Entity<Option<Vec<Entity<Room>>>>,
|
|
||||||
global_result: Entity<Option<Vec<Entity<Room>>>>,
|
|
||||||
// Rooms
|
|
||||||
indicator: Entity<Option<RoomKind>>,
|
indicator: Entity<Option<RoomKind>>,
|
||||||
active_filter: Entity<RoomKind>,
|
active_filter: Entity<RoomKind>,
|
||||||
// GPUI
|
|
||||||
focus_handle: FocusHandle,
|
/// Event subscriptions
|
||||||
image_cache: Entity<RetainAllImageCache>,
|
_subscriptions: SmallVec<[Subscription; 3]>,
|
||||||
#[allow(dead_code)]
|
|
||||||
subscriptions: SmallVec<[Subscription; 3]>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Sidebar {
|
impl Sidebar {
|
||||||
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
|
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||||
cx.new(|cx| Self::view(window, cx))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn view(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
|
||||||
let active_filter = cx.new(|_| RoomKind::Ongoing);
|
let active_filter = cx.new(|_| RoomKind::Ongoing);
|
||||||
let indicator = cx.new(|_| None);
|
let indicator = cx.new(|_| None);
|
||||||
let local_result = cx.new(|_| None);
|
let search_results = cx.new(|_| None);
|
||||||
let global_result = cx.new(|_| None);
|
|
||||||
let cancel_handle = cx.new(|_| None);
|
|
||||||
|
|
||||||
let find_input =
|
let find_input =
|
||||||
cx.new(|cx| InputState::new(window, cx).placeholder(t!("sidebar.search_label")));
|
cx.new(|cx| InputState::new(window, cx).placeholder(t!("sidebar.search_label")));
|
||||||
|
|
||||||
let registry = Registry::global(cx);
|
let chat = ChatRegistry::global(cx);
|
||||||
let mut subscriptions = smallvec![];
|
let mut subscriptions = smallvec![];
|
||||||
|
|
||||||
subscriptions.push(
|
subscriptions.push(
|
||||||
@@ -88,8 +85,8 @@ impl Sidebar {
|
|||||||
|
|
||||||
subscriptions.push(
|
subscriptions.push(
|
||||||
// Subscribe for registry new events
|
// Subscribe for registry new events
|
||||||
cx.subscribe_in(®istry, window, move |this, _, event, _window, cx| {
|
cx.subscribe_in(&chat, window, move |this, _, event, _window, cx| {
|
||||||
if let RegistryEvent::NewRequest(kind) = event {
|
if let ChatEvent::NewChatRequest(kind) = event {
|
||||||
this.indicator.update(cx, |this, cx| {
|
this.indicator.update(cx, |this, cx| {
|
||||||
*this = Some(kind.to_owned());
|
*this = Some(kind.to_owned());
|
||||||
cx.notify();
|
cx.notify();
|
||||||
@@ -102,11 +99,13 @@ impl Sidebar {
|
|||||||
// Subscribe for find input events
|
// Subscribe for find input events
|
||||||
cx.subscribe_in(&find_input, window, |this, state, event, window, cx| {
|
cx.subscribe_in(&find_input, window, |this, state, event, window, cx| {
|
||||||
match event {
|
match event {
|
||||||
InputEvent::PressEnter { .. } => this.search(window, cx),
|
InputEvent::PressEnter { .. } => {
|
||||||
|
this.search(window, cx);
|
||||||
|
}
|
||||||
InputEvent::Change => {
|
InputEvent::Change => {
|
||||||
// Clear the result when input is empty
|
// Clear the result when input is empty
|
||||||
if state.read(cx).value().is_empty() {
|
if state.read(cx).value().is_empty() {
|
||||||
this.clear_search_results(window, cx);
|
this.clear(window, cx);
|
||||||
} else {
|
} else {
|
||||||
// Run debounced search
|
// Run debounced search
|
||||||
this.find_debouncer.fire_new(
|
this.find_debouncer.fire_new(
|
||||||
@@ -128,73 +127,65 @@ impl Sidebar {
|
|||||||
image_cache: RetainAllImageCache::new(cx),
|
image_cache: RetainAllImageCache::new(cx),
|
||||||
find_debouncer: DebouncedDelay::new(),
|
find_debouncer: DebouncedDelay::new(),
|
||||||
finding: false,
|
finding: false,
|
||||||
cancel_handle,
|
|
||||||
indicator,
|
indicator,
|
||||||
active_filter,
|
active_filter,
|
||||||
find_input,
|
find_input,
|
||||||
local_result,
|
search_results,
|
||||||
global_result,
|
search_task: None,
|
||||||
subscriptions,
|
_subscriptions: subscriptions,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn request_metadata(public_key: PublicKey) -> Result<(), Error> {
|
async fn nip50(client: &Client, query: &str) -> Result<Vec<Event>, Error> {
|
||||||
let client = nostr_client();
|
|
||||||
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
|
|
||||||
let kinds = vec![Kind::Metadata, Kind::ContactList, Kind::RelayList];
|
|
||||||
let filter = Filter::new().author(public_key).kinds(kinds).limit(10);
|
|
||||||
|
|
||||||
client
|
|
||||||
.subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts))
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
log::info!("Subscribe to get metadata for: {public_key}");
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn create_temp_room(receiver: PublicKey) -> Result<Room, Error> {
|
|
||||||
// Request to get user's metadata
|
|
||||||
Self::request_metadata(receiver).await?;
|
|
||||||
|
|
||||||
// Create a temporary room
|
|
||||||
let room = Room::new(None, vec![receiver]).await?;
|
|
||||||
|
|
||||||
Ok(room)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn nip50(query: &str) -> Result<BTreeSet<Room>, Error> {
|
|
||||||
let client = nostr_client();
|
|
||||||
let signer = client.signer().await?;
|
let signer = client.signer().await?;
|
||||||
let public_key = signer.get_public_key().await?;
|
let public_key = signer.get_public_key().await?;
|
||||||
|
|
||||||
let timeout = Duration::from_secs(2);
|
|
||||||
let mut rooms: BTreeSet<Room> = BTreeSet::new();
|
|
||||||
|
|
||||||
let filter = Filter::new()
|
let filter = Filter::new()
|
||||||
.kind(Kind::Metadata)
|
.kind(Kind::Metadata)
|
||||||
.search(query.to_lowercase())
|
.search(query.to_lowercase())
|
||||||
.limit(FIND_LIMIT);
|
.limit(FIND_LIMIT);
|
||||||
|
|
||||||
if let Ok(events) = client
|
let mut stream = client
|
||||||
.fetch_events_from(SEARCH_RELAYS, filter, timeout)
|
.stream_events_from(SEARCH_RELAYS, filter, Duration::from_secs(3))
|
||||||
.await
|
.await?;
|
||||||
{
|
|
||||||
// Process to verify the search results
|
let mut results: Vec<Event> = Vec::with_capacity(FIND_LIMIT);
|
||||||
for event in events.into_iter().unique_by(|event| event.pubkey) {
|
|
||||||
|
while let Some(event) = stream.next().await {
|
||||||
// Skip if author is match current user
|
// Skip if author is match current user
|
||||||
if event.pubkey == public_key {
|
if event.pubkey == public_key {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return a temporary room
|
// Skip if the event has already been added
|
||||||
if let Ok(room) = Self::create_temp_room(event.pubkey).await {
|
if results.iter().any(|this| this.pubkey == event.pubkey) {
|
||||||
rooms.insert(room);
|
continue;
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(rooms)
|
results.push(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
if results.is_empty() {
|
||||||
|
return Err(anyhow!("No results for query {query}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all public keys
|
||||||
|
let public_keys: Vec<PublicKey> = results.iter().map(|event| event.pubkey).collect();
|
||||||
|
|
||||||
|
// Fetch metadata and contact lists if public keys is not empty
|
||||||
|
if !public_keys.is_empty() {
|
||||||
|
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
|
||||||
|
let filter = Filter::new()
|
||||||
|
.kinds(vec![Kind::Metadata, Kind::ContactList])
|
||||||
|
.limit(public_keys.len() * 2)
|
||||||
|
.authors(public_keys);
|
||||||
|
|
||||||
|
client
|
||||||
|
.subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts))
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(results)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn debounced_search(&self, window: &mut Window, cx: &mut Context<Self>) -> Task<()> {
|
fn debounced_search(&self, window: &mut Window, cx: &mut Context<Self>) -> Task<()> {
|
||||||
@@ -206,155 +197,136 @@ impl Sidebar {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn search_by_nip50(
|
fn search_by_nip50(&mut self, query: &str, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
&mut self,
|
let account = Account::global(cx);
|
||||||
query: &str,
|
let public_key = account.read(cx).public_key();
|
||||||
rx: smol::channel::Receiver<()>,
|
|
||||||
window: &mut Window,
|
let nostr = NostrRegistry::global(cx);
|
||||||
cx: &mut Context<Self>,
|
let client = nostr.read(cx).client();
|
||||||
) {
|
|
||||||
let query = query.to_owned();
|
let query = query.to_owned();
|
||||||
let query_cloned = query.clone();
|
|
||||||
|
|
||||||
let task = smol::future::or(
|
self.search_task = Some(cx.spawn_in(window, async move |this, cx| {
|
||||||
Tokio::spawn(cx, async move { Self::nip50(&query).await.ok() }),
|
let result = Self::nip50(&client, &query).await;
|
||||||
Tokio::spawn(cx, async move {
|
|
||||||
let _ = rx.recv().await.is_ok();
|
|
||||||
None
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
cx.spawn_in(window, async move |this, cx| {
|
|
||||||
match task.await {
|
|
||||||
Ok(Some(results)) => {
|
|
||||||
this.update_in(cx, |this, window, cx| {
|
this.update_in(cx, |this, window, cx| {
|
||||||
let msg = t!("sidebar.empty", query = query_cloned);
|
match result {
|
||||||
let rooms = results.into_iter().map(|r| cx.new(|_| r)).collect_vec();
|
Ok(results) => {
|
||||||
|
let rooms = results
|
||||||
|
.into_iter()
|
||||||
|
.map(|event| {
|
||||||
|
cx.new(|_| Room::new(None, public_key, vec![event.pubkey]))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
if rooms.is_empty() {
|
this.set_results(rooms, cx);
|
||||||
window.push_notification(msg, cx);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.results(rooms, true, window, cx);
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
}
|
|
||||||
// User cancelled the search
|
|
||||||
Ok(None) => {
|
|
||||||
cx.update(|window, cx| {
|
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
this.set_finding(false, window, cx);
|
|
||||||
this.set_cancel_handle(None, cx);
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
}
|
|
||||||
// Async task failed
|
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
this.update_in(cx, |this, window, cx| {
|
|
||||||
window.push_notification(e.to_string(), cx);
|
window.push_notification(e.to_string(), cx);
|
||||||
this.set_finding(false, window, cx);
|
|
||||||
this.set_cancel_handle(None, cx);
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
this.set_finding(false, window, cx);
|
||||||
})
|
})
|
||||||
.detach();
|
.ok();
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn search_by_nip05(&mut self, query: &str, window: &mut Window, cx: &mut Context<Self>) {
|
fn search_by_nip05(&mut self, query: &str, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let client = nostr.read(cx).client();
|
||||||
let address = query.to_owned();
|
let address = query.to_owned();
|
||||||
|
|
||||||
let task = Tokio::spawn(cx, async move {
|
let task = Tokio::spawn(cx, async move {
|
||||||
if let Ok(profile) = common::nip05::nip05_profile(&address).await {
|
match common::nip05_profile(&address).await {
|
||||||
Self::create_temp_room(profile.public_key).await
|
Ok(profile) => {
|
||||||
} else {
|
let signer = client.signer().await?;
|
||||||
Err(anyhow!(t!("sidebar.addr_error")))
|
let public_key = signer.get_public_key().await?;
|
||||||
|
let receivers = vec![profile.public_key];
|
||||||
|
let room = Room::new(None, public_key, receivers);
|
||||||
|
|
||||||
|
Ok(room)
|
||||||
|
}
|
||||||
|
Err(e) => Err(anyhow!(e)),
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
cx.spawn_in(window, async move |this, cx| {
|
self.search_task = Some(cx.spawn_in(window, async move |this, cx| {
|
||||||
match task.await {
|
let result = task.await;
|
||||||
|
|
||||||
|
this.update_in(cx, |this, window, cx| {
|
||||||
|
match result {
|
||||||
Ok(Ok(room)) => {
|
Ok(Ok(room)) => {
|
||||||
cx.update(|window, cx| {
|
this.set_results(vec![cx.new(|_| room)], cx);
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
this.results(vec![cx.new(|_| room)], true, window, cx);
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
}
|
}
|
||||||
Ok(Err(e)) => {
|
Ok(Err(e)) => {
|
||||||
cx.update(|window, cx| {
|
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
window.push_notification(e.to_string(), cx);
|
window.push_notification(e.to_string(), cx);
|
||||||
this.set_cancel_handle(None, cx);
|
|
||||||
this.set_finding(false, window, cx);
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
cx.update(|window, cx| {
|
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
window.push_notification(e.to_string(), cx);
|
window.push_notification(e.to_string(), cx);
|
||||||
this.set_cancel_handle(None, cx);
|
}
|
||||||
|
}
|
||||||
this.set_finding(false, window, cx);
|
this.set_finding(false, window, cx);
|
||||||
})
|
})
|
||||||
})
|
|
||||||
.ok();
|
.ok();
|
||||||
}
|
}));
|
||||||
};
|
|
||||||
})
|
|
||||||
.detach();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn search_by_pubkey(&mut self, query: &str, window: &mut Window, cx: &mut Context<Self>) {
|
fn search_by_pubkey(&mut self, query: &str, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let client = nostr.read(cx).client();
|
||||||
|
|
||||||
let Ok(public_key) = query.to_public_key() else {
|
let Ok(public_key) = query.to_public_key() else {
|
||||||
window.push_notification(t!("common.pubkey_invalid"), cx);
|
window.push_notification("Public Key is invalid", cx);
|
||||||
self.set_finding(false, window, cx);
|
self.set_finding(false, window, cx);
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
let task: Task<Result<Room, Error>> = cx.background_spawn(async move {
|
let task: Task<Result<Room, Error>> = cx.background_spawn(async move {
|
||||||
// Create a gift wrap event to represent as room
|
let signer = client.signer().await?;
|
||||||
Self::create_temp_room(public_key).await
|
let author = signer.get_public_key().await?;
|
||||||
|
|
||||||
|
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
|
||||||
|
let receivers = vec![public_key];
|
||||||
|
let room = Room::new(None, author, receivers);
|
||||||
|
|
||||||
|
let filter = Filter::new()
|
||||||
|
.kinds(vec![Kind::Metadata, Kind::ContactList])
|
||||||
|
.author(public_key)
|
||||||
|
.limit(2);
|
||||||
|
|
||||||
|
client
|
||||||
|
.subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts))
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(room)
|
||||||
});
|
});
|
||||||
|
|
||||||
cx.spawn_in(window, async move |this, cx| {
|
self.search_task = Some(cx.spawn_in(window, async move |this, cx| {
|
||||||
match task.await {
|
let result = task.await;
|
||||||
Ok(room) => {
|
|
||||||
cx.update(|window, cx| {
|
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
let registry = Registry::read_global(cx);
|
|
||||||
let result = registry.search_by_public_key(public_key, cx);
|
|
||||||
|
|
||||||
if !result.is_empty() {
|
this.update_in(cx, |this, window, cx| {
|
||||||
this.results(result, false, window, cx);
|
match result {
|
||||||
|
Ok(room) => {
|
||||||
|
let chat = ChatRegistry::global(cx);
|
||||||
|
let local_results = chat.read(cx).search_by_public_key(public_key, cx);
|
||||||
|
|
||||||
|
if !local_results.is_empty() {
|
||||||
|
this.set_results(local_results, cx);
|
||||||
} else {
|
} else {
|
||||||
this.results(vec![cx.new(|_| room)], true, window, cx);
|
this.set_results(vec![cx.new(|_| room)], cx);
|
||||||
}
|
}
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
cx.update(|window, cx| {
|
|
||||||
window.push_notification(e.to_string(), cx);
|
window.push_notification(e.to_string(), cx);
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
this.set_finding(false, window, cx);
|
||||||
})
|
})
|
||||||
.detach();
|
.ok();
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn search(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
fn search(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
let (tx, rx) = smol::channel::bounded::<()>(1);
|
|
||||||
let tx_clone = tx.clone();
|
|
||||||
|
|
||||||
// Return if the query is empty
|
// Return if the query is empty
|
||||||
if self.find_input.read(cx).value().is_empty() {
|
if self.find_input.read(cx).value().is_empty() {
|
||||||
return;
|
return;
|
||||||
@@ -362,15 +334,12 @@ impl Sidebar {
|
|||||||
|
|
||||||
// Return if search is in progress
|
// Return if search is in progress
|
||||||
if self.finding {
|
if self.finding {
|
||||||
if self.cancel_handle.read(cx).is_none() {
|
if self.search_task.is_none() {
|
||||||
window.push_notification(t!("sidebar.search_in_progress"), cx);
|
window.push_notification("There is another search in progress", cx);
|
||||||
return;
|
return;
|
||||||
} else {
|
} else {
|
||||||
// This is a hack to cancel ongoing search request
|
// Cancel ongoing search request
|
||||||
cx.background_spawn(async move {
|
self.search_task = None;
|
||||||
tx.send(()).await.ok();
|
|
||||||
})
|
|
||||||
.detach();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -395,48 +364,25 @@ impl Sidebar {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let chats = Registry::read_global(cx);
|
|
||||||
// Get all local results with current query
|
// Get all local results with current query
|
||||||
let local_results = chats.search(&query, cx);
|
let chat = ChatRegistry::global(cx);
|
||||||
|
let local_results = chat.read(cx).search(&query, cx);
|
||||||
|
|
||||||
if !local_results.is_empty() {
|
|
||||||
// Try to update with local results first
|
// Try to update with local results first
|
||||||
self.results(local_results, false, window, cx);
|
if !local_results.is_empty() {
|
||||||
} else {
|
self.set_results(local_results, cx);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
// If no local results, try global search via NIP-50
|
// If no local results, try global search via NIP-50
|
||||||
self.set_cancel_handle(Some(tx_clone), cx);
|
self.search_by_nip50(&query, window, cx);
|
||||||
self.search_by_nip50(&query, rx, window, cx);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn results(
|
fn set_results(&mut self, rooms: Vec<Entity<Room>>, cx: &mut Context<Self>) {
|
||||||
&mut self,
|
self.search_results.update(cx, |this, cx| {
|
||||||
rooms: Vec<Entity<Room>>,
|
|
||||||
global: bool,
|
|
||||||
window: &mut Window,
|
|
||||||
cx: &mut Context<Self>,
|
|
||||||
) {
|
|
||||||
if self.finding {
|
|
||||||
self.set_finding(false, window, cx);
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.cancel_handle.read(cx).is_some() {
|
|
||||||
self.set_cancel_handle(None, cx);
|
|
||||||
}
|
|
||||||
|
|
||||||
if !rooms.is_empty() {
|
|
||||||
if global {
|
|
||||||
self.global_result.update(cx, |this, cx| {
|
|
||||||
*this = Some(rooms);
|
*this = Some(rooms);
|
||||||
cx.notify();
|
cx.notify();
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
self.local_result.update(cx, |this, cx| {
|
|
||||||
*this = Some(rooms);
|
|
||||||
cx.notify();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_finding(&mut self, status: bool, _window: &mut Window, cx: &mut Context<Self>) {
|
fn set_finding(&mut self, status: bool, _window: &mut Window, cx: &mut Context<Self>) {
|
||||||
@@ -450,31 +396,14 @@ impl Sidebar {
|
|||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_cancel_handle(
|
fn clear(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
&mut self,
|
|
||||||
handle: Option<smol::channel::Sender<()>>,
|
|
||||||
cx: &mut Context<Self>,
|
|
||||||
) {
|
|
||||||
self.cancel_handle.update(cx, |this, cx| {
|
|
||||||
*this = handle;
|
|
||||||
cx.notify();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn clear_search_results(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
||||||
// Reset the input state
|
// Reset the input state
|
||||||
if self.finding {
|
if self.finding {
|
||||||
self.set_finding(false, window, cx);
|
self.set_finding(false, window, cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear all local results
|
// Clear all local results
|
||||||
self.local_result.update(cx, |this, cx| {
|
self.search_results.update(cx, |this, cx| {
|
||||||
*this = None;
|
|
||||||
cx.notify();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Clear all global results
|
|
||||||
self.global_result.update(cx, |this, cx| {
|
|
||||||
*this = None;
|
*this = None;
|
||||||
cx.notify();
|
cx.notify();
|
||||||
});
|
});
|
||||||
@@ -496,10 +425,11 @@ impl Sidebar {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn open_room(&mut self, id: u64, window: &mut Window, cx: &mut Context<Self>) {
|
fn open_room(&mut self, id: u64, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
let room = if let Some(room) = Registry::read_global(cx).room(&id, cx) {
|
let chat = ChatRegistry::global(cx);
|
||||||
|
let room = if let Some(room) = chat.read(cx).room(&id, cx) {
|
||||||
room
|
room
|
||||||
} else {
|
} else {
|
||||||
let Some(result) = self.global_result.read(cx).as_ref() else {
|
let Some(result) = self.search_results.read(cx).as_ref() else {
|
||||||
window.push_notification(t!("common.room_error"), cx);
|
window.push_notification(t!("common.room_error"), cx);
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
@@ -510,28 +440,31 @@ impl Sidebar {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Clear all search results
|
// Clear all search results
|
||||||
self.clear_search_results(window, cx);
|
self.clear(window, cx);
|
||||||
|
|
||||||
room
|
room
|
||||||
};
|
};
|
||||||
|
|
||||||
Registry::global(cx).update(cx, |this, cx| {
|
chat.update(cx, |this, cx| {
|
||||||
this.push_room(room, cx);
|
this.push_room(room, cx);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fn on_reload(&mut self, _ev: &Reload, window: &mut Window, cx: &mut Context<Self>) {
|
fn on_reload(&mut self, _ev: &Reload, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
Registry::global(cx).update(cx, |this, cx| {
|
ChatRegistry::global(cx).update(cx, |this, cx| {
|
||||||
this.load_rooms(window, cx);
|
this.get_rooms(cx);
|
||||||
});
|
});
|
||||||
window.push_notification(t!("common.refreshed"), cx);
|
window.push_notification(t!("common.refreshed"), cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn on_manage(&mut self, _ev: &RelayStatus, window: &mut Window, cx: &mut Context<Self>) {
|
fn on_manage(&mut self, _ev: &RelayStatus, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let client = nostr.read(cx).client();
|
||||||
|
|
||||||
let task: Task<Result<Vec<Relay>, Error>> = cx.background_spawn(async move {
|
let task: Task<Result<Vec<Relay>, Error>> = cx.background_spawn(async move {
|
||||||
let client = nostr_client();
|
let id = SubscriptionId::new(GIFTWRAP_SUBSCRIPTION);
|
||||||
let app_state = app_state();
|
let subscription = client.subscription(&id).await;
|
||||||
let subscription = client.subscription(&app_state.gift_wrap_sub_id).await;
|
|
||||||
let mut relays: Vec<Relay> = vec![];
|
let mut relays: Vec<Relay> = vec![];
|
||||||
|
|
||||||
for (url, _filter) in subscription.into_iter() {
|
for (url, _filter) in subscription.into_iter() {
|
||||||
@@ -612,9 +545,15 @@ impl Sidebar {
|
|||||||
let mut items = Vec::with_capacity(range.end - range.start);
|
let mut items = Vec::with_capacity(range.end - range.start);
|
||||||
|
|
||||||
for ix in range {
|
for ix in range {
|
||||||
if let Some(room) = rooms.get(ix) {
|
let Some(room) = rooms.get(ix) else {
|
||||||
|
items.push(RoomListItem::new(ix));
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
let this = room.read(cx);
|
let this = room.read(cx);
|
||||||
let room_id = this.id;
|
let room_id = this.id;
|
||||||
|
let member = this.display_member(cx);
|
||||||
|
|
||||||
let handler = cx.listener({
|
let handler = cx.listener({
|
||||||
move |this, _, window, cx| {
|
move |this, _, window, cx| {
|
||||||
this.open_room(room_id, window, cx);
|
this.open_room(room_id, window, cx);
|
||||||
@@ -626,14 +565,11 @@ impl Sidebar {
|
|||||||
.room_id(room_id)
|
.room_id(room_id)
|
||||||
.name(this.display_name(cx))
|
.name(this.display_name(cx))
|
||||||
.avatar(this.display_image(proxy, cx))
|
.avatar(this.display_image(proxy, cx))
|
||||||
.created_at(this.created_at.to_ago())
|
.public_key(member.public_key())
|
||||||
.public_key(this.members[0])
|
|
||||||
.kind(this.kind)
|
.kind(this.kind)
|
||||||
|
.created_at(this.created_at.to_ago())
|
||||||
.on_click(handler),
|
.on_click(handler),
|
||||||
)
|
)
|
||||||
} else {
|
|
||||||
items.push(RoomListItem::new(ix));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
items
|
items
|
||||||
@@ -648,14 +584,6 @@ impl Panel for Sidebar {
|
|||||||
fn title(&self, _cx: &App) -> AnyElement {
|
fn title(&self, _cx: &App) -> AnyElement {
|
||||||
self.name.clone().into_any_element()
|
self.name.clone().into_any_element()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn popup_menu(&self, menu: PopupMenu, _cx: &App) -> PopupMenu {
|
|
||||||
menu.track_focus(&self.focus_handle)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn toolbar_buttons(&self, _window: &Window, _cx: &App) -> Vec<Button> {
|
|
||||||
vec![]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EventEmitter<PanelEvent> for Sidebar {}
|
impl EventEmitter<PanelEvent> for Sidebar {}
|
||||||
@@ -668,20 +596,18 @@ 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 {
|
||||||
let registry = Registry::read_global(cx);
|
let chat = ChatRegistry::global(cx);
|
||||||
let loading = registry.unwrapping_status.read(cx) != &UnwrappingStatus::Complete;
|
let loading = chat.read(cx).loading;
|
||||||
|
|
||||||
// Get rooms from either search results or the chat registry
|
// Get rooms from either search results or the chat registry
|
||||||
let rooms = if let Some(results) = self.local_result.read(cx).as_ref() {
|
let rooms = if let Some(results) = self.search_results.read(cx).as_ref() {
|
||||||
results.to_owned()
|
|
||||||
} else if let Some(results) = self.global_result.read(cx).as_ref() {
|
|
||||||
results.to_owned()
|
results.to_owned()
|
||||||
} else {
|
} else {
|
||||||
#[allow(clippy::collapsible_else_if)]
|
// Filter rooms based on the active filter
|
||||||
if self.active_filter.read(cx) == &RoomKind::Ongoing {
|
if self.active_filter.read(cx) == &RoomKind::Ongoing {
|
||||||
registry.ongoing_rooms(cx)
|
chat.read(cx).ongoing_rooms(cx)
|
||||||
} else {
|
} else {
|
||||||
registry.request_rooms(cx)
|
chat.read(cx).request_rooms(cx)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -716,13 +642,19 @@ impl Render for Sidebar {
|
|||||||
.cleanable()
|
.cleanable()
|
||||||
.appearance(true)
|
.appearance(true)
|
||||||
.text_xs()
|
.text_xs()
|
||||||
.suffix(
|
.map(|this| {
|
||||||
|
if !self.find_input.read(cx).loading {
|
||||||
|
this.suffix(
|
||||||
Button::new("find")
|
Button::new("find")
|
||||||
.icon(IconName::Search)
|
.icon(IconName::Search)
|
||||||
.tooltip(t!("sidebar.search_tooltip"))
|
.tooltip(t!("sidebar.search_tooltip"))
|
||||||
.transparent()
|
.transparent()
|
||||||
.small(),
|
.small(),
|
||||||
),
|
)
|
||||||
|
} else {
|
||||||
|
this
|
||||||
|
}
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
// Chat Rooms
|
// Chat Rooms
|
||||||
@@ -745,9 +677,9 @@ impl Render for Sidebar {
|
|||||||
.tooltip(t!("sidebar.all_conversations_tooltip"))
|
.tooltip(t!("sidebar.all_conversations_tooltip"))
|
||||||
.when_some(self.indicator.read(cx).as_ref(), |this, kind| {
|
.when_some(self.indicator.read(cx).as_ref(), |this, kind| {
|
||||||
this.when(kind == &RoomKind::Ongoing, |this| {
|
this.when(kind == &RoomKind::Ongoing, |this| {
|
||||||
this.child(deferred(
|
this.child(
|
||||||
div().size_1().rounded_full().bg(cx.theme().cursor),
|
div().size_1().rounded_full().bg(cx.theme().cursor),
|
||||||
))
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.small()
|
.small()
|
||||||
@@ -766,9 +698,9 @@ impl Render for Sidebar {
|
|||||||
.tooltip(t!("sidebar.requests_tooltip"))
|
.tooltip(t!("sidebar.requests_tooltip"))
|
||||||
.when_some(self.indicator.read(cx).as_ref(), |this, kind| {
|
.when_some(self.indicator.read(cx).as_ref(), |this, kind| {
|
||||||
this.when(kind != &RoomKind::Ongoing, |this| {
|
this.when(kind != &RoomKind::Ongoing, |this| {
|
||||||
this.child(deferred(
|
this.child(
|
||||||
div().size_1().rounded_full().bg(cx.theme().cursor),
|
div().size_1().rounded_full().bg(cx.theme().cursor),
|
||||||
))
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.small()
|
.small()
|
||||||
@@ -813,6 +745,7 @@ impl Render for Sidebar {
|
|||||||
this.child(deferred(
|
this.child(deferred(
|
||||||
v_flex()
|
v_flex()
|
||||||
.py_2()
|
.py_2()
|
||||||
|
.px_1p5()
|
||||||
.gap_1p5()
|
.gap_1p5()
|
||||||
.items_center()
|
.items_center()
|
||||||
.justify_center()
|
.justify_center()
|
||||||
@@ -836,6 +769,7 @@ impl Render for Sidebar {
|
|||||||
this.child(deferred(
|
this.child(deferred(
|
||||||
v_flex()
|
v_flex()
|
||||||
.py_2()
|
.py_2()
|
||||||
|
.px_1p5()
|
||||||
.gap_1p5()
|
.gap_1p5()
|
||||||
.items_center()
|
.items_center()
|
||||||
.justify_center()
|
.justify_center()
|
||||||
@@ -0,0 +1,393 @@
|
|||||||
|
use std::str::FromStr;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use anyhow::{anyhow, Error};
|
||||||
|
use common::{nip96_upload, shorten_pubkey};
|
||||||
|
use gpui::prelude::FluentBuilder;
|
||||||
|
use gpui::{
|
||||||
|
div, img, App, AppContext, ClipboardItem, Context, Entity, Flatten, IntoElement, ParentElement,
|
||||||
|
PathPromptOptions, Render, SharedString, Styled, Task, Window,
|
||||||
|
};
|
||||||
|
use gpui_tokio::Tokio;
|
||||||
|
use nostr_sdk::prelude::*;
|
||||||
|
use settings::AppSettings;
|
||||||
|
use smallvec::{smallvec, SmallVec};
|
||||||
|
use smol::fs;
|
||||||
|
use state::NostrRegistry;
|
||||||
|
use theme::ActiveTheme;
|
||||||
|
use ui::button::{Button, ButtonVariants};
|
||||||
|
use ui::input::{InputState, TextInput};
|
||||||
|
use ui::{h_flex, v_flex, ContextModal, Disableable, IconName, Sizable, StyledExt};
|
||||||
|
|
||||||
|
pub mod viewer;
|
||||||
|
|
||||||
|
pub fn init(window: &mut Window, cx: &mut App) -> Entity<UserProfile> {
|
||||||
|
cx.new(|cx| UserProfile::new(window, cx))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct UserProfile {
|
||||||
|
/// User profile
|
||||||
|
profile: Option<Profile>,
|
||||||
|
|
||||||
|
/// User's name text input
|
||||||
|
name_input: Entity<InputState>,
|
||||||
|
|
||||||
|
/// User's avatar url text input
|
||||||
|
avatar_input: Entity<InputState>,
|
||||||
|
|
||||||
|
/// User's bio multi line input
|
||||||
|
bio_input: Entity<InputState>,
|
||||||
|
|
||||||
|
/// User's website url text input
|
||||||
|
website_input: Entity<InputState>,
|
||||||
|
|
||||||
|
/// Uploading state
|
||||||
|
uploading: bool,
|
||||||
|
|
||||||
|
/// Copied states
|
||||||
|
copied: bool,
|
||||||
|
|
||||||
|
/// Async operations
|
||||||
|
_tasks: SmallVec<[Task<()>; 1]>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UserProfile {
|
||||||
|
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||||
|
let name_input = cx.new(|cx| InputState::new(window, cx).placeholder("Alice"));
|
||||||
|
let avatar_input = cx.new(|cx| InputState::new(window, cx).placeholder("alice.me/a.jpg"));
|
||||||
|
let website_input = cx.new(|cx| InputState::new(window, cx).placeholder("alice.me"));
|
||||||
|
|
||||||
|
// Use multi-line input for bio
|
||||||
|
let bio_input = cx.new(|cx| {
|
||||||
|
InputState::new(window, cx)
|
||||||
|
.multi_line()
|
||||||
|
.auto_grow(3, 8)
|
||||||
|
.placeholder("A short introduce about you.")
|
||||||
|
});
|
||||||
|
|
||||||
|
let get_profile = Self::get_profile(cx);
|
||||||
|
let mut tasks = smallvec![];
|
||||||
|
|
||||||
|
tasks.push(
|
||||||
|
// Get metadata in the background
|
||||||
|
cx.spawn_in(window, async move |this, cx| {
|
||||||
|
if let Ok(profile) = get_profile.await {
|
||||||
|
this.update_in(cx, |this, window, cx| {
|
||||||
|
this.set_profile(profile, window, cx);
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
profile: None,
|
||||||
|
name_input,
|
||||||
|
avatar_input,
|
||||||
|
bio_input,
|
||||||
|
website_input,
|
||||||
|
uploading: false,
|
||||||
|
copied: false,
|
||||||
|
_tasks: tasks,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_profile(cx: &App) -> Task<Result<Profile, Error>> {
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let client = nostr.read(cx).client();
|
||||||
|
|
||||||
|
cx.background_spawn(async move {
|
||||||
|
let signer = client.signer().await?;
|
||||||
|
let public_key = signer.get_public_key().await?;
|
||||||
|
|
||||||
|
let metadata = client
|
||||||
|
.database()
|
||||||
|
.metadata(public_key)
|
||||||
|
.await?
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
Ok(Profile::new(public_key, metadata))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_profile(&mut self, profile: Profile, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
let metadata = profile.metadata();
|
||||||
|
|
||||||
|
self.avatar_input.update(cx, |this, cx| {
|
||||||
|
if let Some(avatar) = metadata.picture.as_ref() {
|
||||||
|
this.set_value(avatar, window, cx);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
self.bio_input.update(cx, |this, cx| {
|
||||||
|
if let Some(bio) = metadata.about.as_ref() {
|
||||||
|
this.set_value(bio, window, cx);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
self.name_input.update(cx, |this, cx| {
|
||||||
|
if let Some(display_name) = metadata.display_name.as_ref() {
|
||||||
|
this.set_value(display_name, window, cx);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
self.website_input.update(cx, |this, cx| {
|
||||||
|
if let Some(website) = metadata.website.as_ref() {
|
||||||
|
this.set_value(website, window, cx);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
self.profile = Some(profile);
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn copy(&mut self, value: String, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
let item = ClipboardItem::new_string(value);
|
||||||
|
cx.write_to_clipboard(item);
|
||||||
|
|
||||||
|
self.set_copied(true, window, cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_copied(&mut self, status: bool, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
self.copied = status;
|
||||||
|
cx.notify();
|
||||||
|
|
||||||
|
if status {
|
||||||
|
self._tasks.push(
|
||||||
|
// Reset the copied state after a delay
|
||||||
|
cx.spawn_in(window, async move |this, cx| {
|
||||||
|
cx.background_executor().timer(Duration::from_secs(2)).await;
|
||||||
|
cx.update(|window, cx| {
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.set_copied(false, window, cx);
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn uploading(&mut self, status: bool, cx: &mut Context<Self>) {
|
||||||
|
self.uploading = status;
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn upload(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
self.uploading(true, cx);
|
||||||
|
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let client = nostr.read(cx).client();
|
||||||
|
|
||||||
|
// Get the user's configured NIP96 server
|
||||||
|
let nip96_server = AppSettings::get_media_server(cx);
|
||||||
|
|
||||||
|
// Open native file dialog
|
||||||
|
let paths = cx.prompt_for_paths(PathPromptOptions {
|
||||||
|
files: true,
|
||||||
|
directories: false,
|
||||||
|
multiple: false,
|
||||||
|
prompt: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
let task = Tokio::spawn(cx, async move {
|
||||||
|
match Flatten::flatten(paths.await.map_err(|e| e.into())) {
|
||||||
|
Ok(Some(mut paths)) => {
|
||||||
|
if let Some(path) = paths.pop() {
|
||||||
|
let file = fs::read(path).await?;
|
||||||
|
let url = nip96_upload(&client, &nip96_server, file).await?;
|
||||||
|
|
||||||
|
Ok(url)
|
||||||
|
} else {
|
||||||
|
Err(anyhow!("Path not found"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(None) => Err(anyhow!("User cancelled")),
|
||||||
|
Err(e) => Err(anyhow!("File dialog error: {e}")),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
cx.spawn_in(window, async move |this, cx| {
|
||||||
|
let result = Flatten::flatten(task.await.map_err(|e| e.into()));
|
||||||
|
|
||||||
|
this.update_in(cx, |this, window, cx| {
|
||||||
|
match result {
|
||||||
|
Ok(Ok(url)) => {
|
||||||
|
this.avatar_input.update(cx, |this, cx| {
|
||||||
|
this.set_value(url.to_string(), window, cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
window.push_notification(e.to_string(), cx);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("Failed to upload avatar: {e}");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this.uploading(false, cx);
|
||||||
|
})
|
||||||
|
.expect("Entity has been released");
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_metadata(&mut self, cx: &mut Context<Self>) -> Task<Result<Profile, Error>> {
|
||||||
|
let avatar = self.avatar_input.read(cx).value().to_string();
|
||||||
|
let name = self.name_input.read(cx).value().to_string();
|
||||||
|
let bio = self.bio_input.read(cx).value().to_string();
|
||||||
|
let website = self.website_input.read(cx).value().to_string();
|
||||||
|
|
||||||
|
// Get the current profile metadata
|
||||||
|
let old_metadata = self
|
||||||
|
.profile
|
||||||
|
.as_ref()
|
||||||
|
.map(|profile| profile.metadata())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
// Construct the new metadata
|
||||||
|
let mut new_metadata = old_metadata.display_name(name).about(bio);
|
||||||
|
|
||||||
|
if let Ok(url) = Url::from_str(&avatar) {
|
||||||
|
new_metadata = new_metadata.picture(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Ok(url) = Url::from_str(&website) {
|
||||||
|
new_metadata = new_metadata.website(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let client = nostr.read(cx).client();
|
||||||
|
let gossip = nostr.read(cx).gossip();
|
||||||
|
|
||||||
|
cx.background_spawn(async move {
|
||||||
|
let signer = client.signer().await?;
|
||||||
|
let public_key = signer.get_public_key().await?;
|
||||||
|
|
||||||
|
let gossip = gossip.read().await;
|
||||||
|
let write_relays = gossip.inbox_relays(&public_key);
|
||||||
|
|
||||||
|
// Ensure connections to the write relays
|
||||||
|
gossip.ensure_connections(&client, &write_relays).await;
|
||||||
|
|
||||||
|
// Sign the new metadata event
|
||||||
|
let event = EventBuilder::metadata(&new_metadata).sign(&signer).await?;
|
||||||
|
|
||||||
|
// Send event to user's write relayss
|
||||||
|
client.send_event_to(write_relays, &event).await?;
|
||||||
|
|
||||||
|
// Return the updated profile
|
||||||
|
let metadata = Metadata::from_json(&event.content).unwrap_or_default();
|
||||||
|
let profile = Profile::new(event.pubkey, metadata);
|
||||||
|
|
||||||
|
Ok(profile)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for UserProfile {
|
||||||
|
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
v_flex()
|
||||||
|
.gap_3()
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.relative()
|
||||||
|
.w_full()
|
||||||
|
.h_32()
|
||||||
|
.items_center()
|
||||||
|
.justify_center()
|
||||||
|
.gap_2()
|
||||||
|
.bg(cx.theme().surface_background)
|
||||||
|
.rounded(cx.theme().radius)
|
||||||
|
.map(|this| {
|
||||||
|
let picture = self.avatar_input.read(cx).value();
|
||||||
|
let source = if picture.is_empty() {
|
||||||
|
"brand/avatar.png"
|
||||||
|
} else {
|
||||||
|
picture.as_str()
|
||||||
|
};
|
||||||
|
this.child(img(source).rounded_full().size_10().flex_shrink_0())
|
||||||
|
})
|
||||||
|
.child(
|
||||||
|
Button::new("upload")
|
||||||
|
.icon(IconName::Upload)
|
||||||
|
.label("Change")
|
||||||
|
.ghost()
|
||||||
|
.small()
|
||||||
|
.disabled(self.uploading)
|
||||||
|
.on_click(cx.listener(move |this, _, window, cx| {
|
||||||
|
this.upload(window, cx);
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.gap_1()
|
||||||
|
.text_sm()
|
||||||
|
.child(SharedString::from("Name:"))
|
||||||
|
.child(TextInput::new(&self.name_input).small()),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.gap_1()
|
||||||
|
.text_sm()
|
||||||
|
.child(SharedString::from("Bio:"))
|
||||||
|
.child(TextInput::new(&self.bio_input).small()),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.gap_1()
|
||||||
|
.text_sm()
|
||||||
|
.child(SharedString::from("Website:"))
|
||||||
|
.child(TextInput::new(&self.website_input).small()),
|
||||||
|
)
|
||||||
|
.when_some(self.profile.as_ref(), |this, profile| {
|
||||||
|
let public_key = profile.public_key();
|
||||||
|
let display = SharedString::from(shorten_pubkey(profile.public_key(), 8));
|
||||||
|
|
||||||
|
this.child(div().my_1().h_px().w_full().bg(cx.theme().border))
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.gap_1()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(cx.theme().text_placeholder)
|
||||||
|
.font_semibold()
|
||||||
|
.child(SharedString::from("Public Key:")),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.gap_2()
|
||||||
|
.w_full()
|
||||||
|
.h_12()
|
||||||
|
.justify_center()
|
||||||
|
.bg(cx.theme().surface_background)
|
||||||
|
.rounded(cx.theme().radius)
|
||||||
|
.text_sm()
|
||||||
|
.child(display)
|
||||||
|
.child(
|
||||||
|
Button::new("copy")
|
||||||
|
.icon({
|
||||||
|
if self.copied {
|
||||||
|
IconName::CheckCircleFill
|
||||||
|
} else {
|
||||||
|
IconName::Copy
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.xsmall()
|
||||||
|
.ghost()
|
||||||
|
.on_click(cx.listener(move |this, _e, window, cx| {
|
||||||
|
this.copy(
|
||||||
|
public_key.to_bech32().unwrap(),
|
||||||
|
window,
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,45 +1,54 @@
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use common::display::RenderedProfile;
|
use common::{nip05_verify, shorten_pubkey, RenderedProfile};
|
||||||
use common::nip05::nip05_verify;
|
|
||||||
use global::nostr_client;
|
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
div, relative, rems, App, AppContext, ClipboardItem, Context, Entity, IntoElement,
|
div, relative, rems, App, AppContext, ClipboardItem, Context, Entity, IntoElement,
|
||||||
ParentElement, Render, SharedString, Styled, Task, Window,
|
ParentElement, Render, SharedString, Styled, Task, Window,
|
||||||
};
|
};
|
||||||
use gpui_tokio::Tokio;
|
use gpui_tokio::Tokio;
|
||||||
use i18n::{shared_t, t};
|
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use registry::Registry;
|
use person::PersonRegistry;
|
||||||
use settings::AppSettings;
|
use settings::AppSettings;
|
||||||
use smallvec::{smallvec, SmallVec};
|
use smallvec::{smallvec, SmallVec};
|
||||||
|
use state::NostrRegistry;
|
||||||
use theme::ActiveTheme;
|
use theme::ActiveTheme;
|
||||||
use ui::avatar::Avatar;
|
use ui::avatar::Avatar;
|
||||||
use ui::button::{Button, ButtonVariants};
|
use ui::button::{Button, ButtonVariants};
|
||||||
use ui::{h_flex, v_flex, Icon, IconName, Sizable, StyledExt};
|
use ui::{h_flex, v_flex, Icon, IconName, Sizable, StyledExt};
|
||||||
|
|
||||||
pub fn init(public_key: PublicKey, window: &mut Window, cx: &mut App) -> Entity<UserProfile> {
|
pub fn init(public_key: PublicKey, window: &mut Window, cx: &mut App) -> Entity<ProfileViewer> {
|
||||||
cx.new(|cx| UserProfile::new(public_key, window, cx))
|
cx.new(|cx| ProfileViewer::new(public_key, window, cx))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct UserProfile {
|
#[derive(Debug)]
|
||||||
|
pub struct ProfileViewer {
|
||||||
profile: Profile,
|
profile: Profile,
|
||||||
|
|
||||||
|
/// Follow status
|
||||||
followed: bool,
|
followed: bool,
|
||||||
|
|
||||||
|
/// Verification status
|
||||||
verified: bool,
|
verified: bool,
|
||||||
|
|
||||||
|
/// Copy status
|
||||||
copied: bool,
|
copied: bool,
|
||||||
|
|
||||||
|
/// Async operations
|
||||||
_tasks: SmallVec<[Task<()>; 1]>,
|
_tasks: SmallVec<[Task<()>; 1]>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl UserProfile {
|
impl ProfileViewer {
|
||||||
pub fn new(target: PublicKey, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
pub fn new(target: PublicKey, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||||
let registry = Registry::read_global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let profile = registry.get_person(&target, cx);
|
let client = nostr.read(cx).client();
|
||||||
|
|
||||||
|
let persons = PersonRegistry::global(cx);
|
||||||
|
let profile = persons.read(cx).get_person(&target, cx);
|
||||||
|
|
||||||
let mut tasks = smallvec![];
|
let mut tasks = smallvec![];
|
||||||
|
|
||||||
let check_follow: Task<Result<bool, Error>> = cx.background_spawn(async move {
|
let check_follow: Task<Result<bool, Error>> = cx.background_spawn(async move {
|
||||||
let client = nostr_client();
|
|
||||||
let signer = client.signer().await?;
|
let signer = client.signer().await?;
|
||||||
let public_key = signer.get_public_key().await?;
|
let public_key = signer.get_public_key().await?;
|
||||||
let contact_list = client.database().contacts_public_keys(public_key).await?;
|
let contact_list = client.database().contacts_public_keys(public_key).await?;
|
||||||
@@ -105,8 +114,9 @@ impl UserProfile {
|
|||||||
self.copied = status;
|
self.copied = status;
|
||||||
cx.notify();
|
cx.notify();
|
||||||
|
|
||||||
// Reset the copied state after a delay
|
|
||||||
if status {
|
if status {
|
||||||
|
self._tasks.push(
|
||||||
|
// Reset the copied state after a delay
|
||||||
cx.spawn_in(window, async move |this, cx| {
|
cx.spawn_in(window, async move |this, cx| {
|
||||||
cx.background_executor().timer(Duration::from_secs(2)).await;
|
cx.background_executor().timer(Duration::from_secs(2)).await;
|
||||||
cx.update(|window, cx| {
|
cx.update(|window, cx| {
|
||||||
@@ -116,16 +126,16 @@ impl UserProfile {
|
|||||||
.ok();
|
.ok();
|
||||||
})
|
})
|
||||||
.ok();
|
.ok();
|
||||||
})
|
}),
|
||||||
.detach();
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Render for UserProfile {
|
impl Render for ProfileViewer {
|
||||||
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 {
|
||||||
let proxy = AppSettings::get_proxy_user_avatars(cx);
|
let proxy = AppSettings::get_proxy_user_avatars(cx);
|
||||||
let bech32 = self.profile.public_key().to_bech32().unwrap();
|
let bech32 = shorten_pubkey(self.profile.public_key(), 16);
|
||||||
let shared_bech32 = SharedString::from(bech32);
|
let shared_bech32 = SharedString::from(bech32);
|
||||||
|
|
||||||
v_flex()
|
v_flex()
|
||||||
@@ -179,33 +189,55 @@ impl Render for UserProfile {
|
|||||||
.bg(cx.theme().elevated_surface_background)
|
.bg(cx.theme().elevated_surface_background)
|
||||||
.text_xs()
|
.text_xs()
|
||||||
.font_semibold()
|
.font_semibold()
|
||||||
.child(shared_t!("profile.unknown")),
|
.child(SharedString::from("Unknown contact")),
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
v_flex()
|
v_flex()
|
||||||
.gap_1()
|
.gap_1()
|
||||||
|
.text_sm()
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
.text_color(cx.theme().text_muted)
|
.text_color(cx.theme().text_muted)
|
||||||
|
.child(SharedString::from("Bio:")),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.p_2()
|
||||||
|
.min_h_16()
|
||||||
|
.rounded(cx.theme().radius)
|
||||||
|
.bg(cx.theme().elevated_surface_background)
|
||||||
|
.child(
|
||||||
|
self.profile
|
||||||
|
.metadata()
|
||||||
|
.about
|
||||||
|
.map(SharedString::from)
|
||||||
|
.unwrap_or(SharedString::from("No bio.")),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(div().my_1().h_px().w_full().bg(cx.theme().border))
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.gap_1()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(cx.theme().text_placeholder)
|
||||||
|
.font_semibold()
|
||||||
.child(SharedString::from("Public Key:")),
|
.child(SharedString::from("Public Key:")),
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
h_flex()
|
h_flex()
|
||||||
.gap_1()
|
.gap_2()
|
||||||
.child(
|
.w_full()
|
||||||
div()
|
.h_12()
|
||||||
.p_2()
|
.justify_center()
|
||||||
.h_7()
|
.bg(cx.theme().surface_background)
|
||||||
.rounded_md()
|
.rounded(cx.theme().radius)
|
||||||
.bg(cx.theme().elevated_surface_background)
|
.text_sm()
|
||||||
.truncate()
|
.child(shared_bech32)
|
||||||
.text_ellipsis()
|
|
||||||
.line_clamp(1)
|
|
||||||
.line_height(relative(1.))
|
|
||||||
.child(shared_bech32),
|
|
||||||
)
|
|
||||||
.child(
|
.child(
|
||||||
Button::new("copy")
|
Button::new("copy")
|
||||||
.icon({
|
.icon({
|
||||||
@@ -215,35 +247,13 @@ impl Render for UserProfile {
|
|||||||
IconName::Copy
|
IconName::Copy
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.cta()
|
.xsmall()
|
||||||
.ghost_alt()
|
.ghost()
|
||||||
.on_click(cx.listener(move |this, _e, window, cx| {
|
.on_click(cx.listener(move |this, _e, window, cx| {
|
||||||
this.copy_pubkey(window, cx);
|
this.copy_pubkey(window, cx);
|
||||||
})),
|
})),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.child(
|
|
||||||
v_flex()
|
|
||||||
.gap_1()
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.text_color(cx.theme().text_muted)
|
|
||||||
.child(shared_t!("profile.label_bio")),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.p_2()
|
|
||||||
.rounded_md()
|
|
||||||
.bg(cx.theme().elevated_surface_background)
|
|
||||||
.child(
|
|
||||||
self.profile
|
|
||||||
.metadata()
|
|
||||||
.about
|
|
||||||
.map(SharedString::from)
|
|
||||||
.unwrap_or(shared_t!("profile.no_bio")),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,447 +0,0 @@
|
|||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use anyhow::Error;
|
|
||||||
use client_keys::ClientKeys;
|
|
||||||
use common::display::RenderedProfile;
|
|
||||||
use global::constants::{ACCOUNT_IDENTIFIER, BUNKER_TIMEOUT};
|
|
||||||
use global::{app_state, nostr_client, SignalKind};
|
|
||||||
use gpui::prelude::FluentBuilder;
|
|
||||||
use gpui::{
|
|
||||||
div, relative, rems, svg, AnyElement, App, AppContext, Context, Entity, EventEmitter,
|
|
||||||
FocusHandle, Focusable, InteractiveElement, IntoElement, ParentElement, Render,
|
|
||||||
RetainAllImageCache, SharedString, StatefulInteractiveElement, Styled, Subscription, Task,
|
|
||||||
WeakEntity, Window,
|
|
||||||
};
|
|
||||||
use i18n::{shared_t, t};
|
|
||||||
use nostr_connect::prelude::*;
|
|
||||||
use nostr_sdk::prelude::*;
|
|
||||||
use smallvec::{smallvec, SmallVec};
|
|
||||||
use theme::ActiveTheme;
|
|
||||||
use ui::avatar::Avatar;
|
|
||||||
use ui::button::{Button, ButtonVariants};
|
|
||||||
use ui::dock_area::panel::{Panel, PanelEvent};
|
|
||||||
use ui::indicator::Indicator;
|
|
||||||
use ui::input::{InputState, TextInput};
|
|
||||||
use ui::notification::Notification;
|
|
||||||
use ui::popup_menu::PopupMenu;
|
|
||||||
use ui::{h_flex, v_flex, ContextModal, Sizable, StyledExt};
|
|
||||||
|
|
||||||
use crate::actions::CoopAuthUrlHandler;
|
|
||||||
use crate::chatspace::ChatSpace;
|
|
||||||
|
|
||||||
pub fn init(
|
|
||||||
profile: Profile,
|
|
||||||
secret: String,
|
|
||||||
window: &mut Window,
|
|
||||||
cx: &mut App,
|
|
||||||
) -> Entity<Account> {
|
|
||||||
cx.new(|cx| Account::new(secret, profile, window, cx))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct Account {
|
|
||||||
profile: Profile,
|
|
||||||
stored_secret: String,
|
|
||||||
is_bunker: bool,
|
|
||||||
is_extension: bool,
|
|
||||||
loading: bool,
|
|
||||||
|
|
||||||
name: SharedString,
|
|
||||||
focus_handle: FocusHandle,
|
|
||||||
image_cache: Entity<RetainAllImageCache>,
|
|
||||||
|
|
||||||
_subscriptions: SmallVec<[Subscription; 1]>,
|
|
||||||
_tasks: SmallVec<[Task<()>; 1]>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Account {
|
|
||||||
fn new(secret: String, profile: Profile, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
|
||||||
let is_bunker = secret.starts_with("bunker://");
|
|
||||||
let is_extension = secret.starts_with("extension");
|
|
||||||
|
|
||||||
let mut subscriptions = smallvec![];
|
|
||||||
|
|
||||||
subscriptions.push(
|
|
||||||
// Clear the local state when user closes the account panel
|
|
||||||
cx.on_release_in(window, move |this, window, cx| {
|
|
||||||
this.stored_secret.clear();
|
|
||||||
this.image_cache.update(cx, |this, cx| {
|
|
||||||
this.clear(window, cx);
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
Self {
|
|
||||||
profile,
|
|
||||||
is_bunker,
|
|
||||||
is_extension,
|
|
||||||
stored_secret: secret,
|
|
||||||
loading: false,
|
|
||||||
name: "Account".into(),
|
|
||||||
focus_handle: cx.focus_handle(),
|
|
||||||
image_cache: RetainAllImageCache::new(cx),
|
|
||||||
_subscriptions: subscriptions,
|
|
||||||
_tasks: smallvec![],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn login(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
||||||
self.set_loading(true, cx);
|
|
||||||
|
|
||||||
if self.is_bunker {
|
|
||||||
if let Ok(uri) = NostrConnectURI::parse(&self.stored_secret) {
|
|
||||||
self.nostr_connect(uri, window, cx);
|
|
||||||
}
|
|
||||||
} else if self.is_extension {
|
|
||||||
self.set_proxy(window, cx);
|
|
||||||
} else if let Ok(enc) = EncryptedSecretKey::from_bech32(&self.stored_secret) {
|
|
||||||
self.keys(enc, window, cx);
|
|
||||||
} else {
|
|
||||||
window.push_notification("Cannot continue with current account", cx);
|
|
||||||
self.set_loading(false, cx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn nostr_connect(&mut self, uri: NostrConnectURI, window: &mut Window, cx: &mut Context<Self>) {
|
|
||||||
let client_keys = ClientKeys::global(cx);
|
|
||||||
let app_keys = client_keys.read(cx).keys();
|
|
||||||
|
|
||||||
let timeout = Duration::from_secs(BUNKER_TIMEOUT);
|
|
||||||
let mut signer = NostrConnect::new(uri, app_keys, timeout, None).unwrap();
|
|
||||||
|
|
||||||
// Handle auth url with the default browser
|
|
||||||
signer.auth_url_handler(CoopAuthUrlHandler);
|
|
||||||
|
|
||||||
self._tasks.push(
|
|
||||||
// Handle connection in the background
|
|
||||||
cx.spawn_in(window, async move |this, cx| {
|
|
||||||
let client = nostr_client();
|
|
||||||
|
|
||||||
match signer.bunker_uri().await {
|
|
||||||
Ok(_) => {
|
|
||||||
// Set the client's signer with the current nostr connect instance
|
|
||||||
client.set_signer(signer).await;
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
this.update_in(cx, |this, window, cx| {
|
|
||||||
this.set_loading(false, cx);
|
|
||||||
window.push_notification(Notification::error(e.to_string()), cx);
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_proxy(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
||||||
ChatSpace::proxy_signer(window, cx);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn keys(&mut self, enc: EncryptedSecretKey, window: &mut Window, cx: &mut Context<Self>) {
|
|
||||||
let pwd_input: Entity<InputState> = cx.new(|cx| InputState::new(window, cx).masked(true));
|
|
||||||
let weak_input = pwd_input.downgrade();
|
|
||||||
|
|
||||||
let error: Entity<Option<SharedString>> = cx.new(|_| None);
|
|
||||||
let weak_error = error.downgrade();
|
|
||||||
|
|
||||||
let entity = cx.weak_entity();
|
|
||||||
|
|
||||||
window.open_modal(cx, move |this, _window, cx| {
|
|
||||||
let entity = entity.clone();
|
|
||||||
let entity_clone = entity.clone();
|
|
||||||
let weak_input = weak_input.clone();
|
|
||||||
let weak_error = weak_error.clone();
|
|
||||||
|
|
||||||
this.overlay_closable(false)
|
|
||||||
.show_close(false)
|
|
||||||
.keyboard(false)
|
|
||||||
.confirm()
|
|
||||||
.on_cancel(move |_, _window, cx| {
|
|
||||||
entity
|
|
||||||
.update(cx, |this, cx| {
|
|
||||||
this.set_loading(false, cx);
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
|
|
||||||
// true to close the modal
|
|
||||||
true
|
|
||||||
})
|
|
||||||
.on_ok(move |_, window, cx| {
|
|
||||||
let weak_error = weak_error.clone();
|
|
||||||
let password = weak_input
|
|
||||||
.read_with(cx, |state, _cx| state.value().to_owned())
|
|
||||||
.ok();
|
|
||||||
|
|
||||||
entity_clone
|
|
||||||
.update(cx, |this, cx| {
|
|
||||||
this.verify_keys(enc, password, weak_error, window, cx);
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
|
|
||||||
// false to keep the modal open
|
|
||||||
false
|
|
||||||
})
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.w_full()
|
|
||||||
.flex()
|
|
||||||
.flex_col()
|
|
||||||
.gap_1()
|
|
||||||
.text_sm()
|
|
||||||
.child(shared_t!("login.password_to_decrypt"))
|
|
||||||
.child(TextInput::new(&pwd_input).small())
|
|
||||||
.when_some(error.read(cx).as_ref(), |this, error| {
|
|
||||||
this.child(
|
|
||||||
div()
|
|
||||||
.text_xs()
|
|
||||||
.italic()
|
|
||||||
.text_color(cx.theme().danger_foreground)
|
|
||||||
.child(error.clone()),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn verify_keys(
|
|
||||||
&mut self,
|
|
||||||
enc: EncryptedSecretKey,
|
|
||||||
password: Option<SharedString>,
|
|
||||||
error: WeakEntity<Option<SharedString>>,
|
|
||||||
window: &mut Window,
|
|
||||||
cx: &mut Context<Self>,
|
|
||||||
) {
|
|
||||||
let Some(password) = password else {
|
|
||||||
error
|
|
||||||
.update(cx, |this, cx| {
|
|
||||||
*this = Some("Password is required".into());
|
|
||||||
cx.notify();
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
if password.is_empty() {
|
|
||||||
error
|
|
||||||
.update(cx, |this, cx| {
|
|
||||||
*this = Some("Password cannot be empty".into());
|
|
||||||
cx.notify();
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let task: Task<Result<SecretKey, Error>> = cx.background_spawn(async move {
|
|
||||||
let secret = enc.decrypt(&password)?;
|
|
||||||
Ok(secret)
|
|
||||||
});
|
|
||||||
|
|
||||||
cx.spawn_in(window, async move |_this, cx| {
|
|
||||||
match task.await {
|
|
||||||
Ok(secret) => {
|
|
||||||
cx.update(|window, cx| {
|
|
||||||
window.close_all_modals(cx);
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
|
|
||||||
let client = nostr_client();
|
|
||||||
let keys = Keys::new(secret);
|
|
||||||
|
|
||||||
// Set the client's signer with the current keys
|
|
||||||
client.set_signer(keys).await
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
error
|
|
||||||
.update(cx, |this, cx| {
|
|
||||||
*this = Some(e.to_string().into());
|
|
||||||
cx.notify();
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.detach();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn logout(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
|
|
||||||
self._tasks.push(
|
|
||||||
// Reset the nostr client in the background
|
|
||||||
cx.background_spawn(async move {
|
|
||||||
let client = nostr_client();
|
|
||||||
let app_state = app_state();
|
|
||||||
|
|
||||||
let filter = Filter::new()
|
|
||||||
.kind(Kind::ApplicationSpecificData)
|
|
||||||
.identifier(ACCOUNT_IDENTIFIER);
|
|
||||||
|
|
||||||
// Delete account
|
|
||||||
client.database().delete(filter).await.ok();
|
|
||||||
|
|
||||||
// Unset the client's signer
|
|
||||||
client.unset_signer().await;
|
|
||||||
|
|
||||||
// Notify the channel about the signer being unset
|
|
||||||
app_state.signal.send(SignalKind::SignerUnset).await;
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
|
|
||||||
self.loading = status;
|
|
||||||
cx.notify();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Panel for Account {
|
|
||||||
fn panel_id(&self) -> SharedString {
|
|
||||||
self.name.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn title(&self, _cx: &App) -> AnyElement {
|
|
||||||
self.name.clone().into_any_element()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn popup_menu(&self, menu: PopupMenu, _cx: &App) -> PopupMenu {
|
|
||||||
menu.track_focus(&self.focus_handle)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn toolbar_buttons(&self, _window: &Window, _cx: &App) -> Vec<Button> {
|
|
||||||
vec![]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl EventEmitter<PanelEvent> for Account {}
|
|
||||||
|
|
||||||
impl Focusable for Account {
|
|
||||||
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
|
|
||||||
self.focus_handle.clone()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Render for Account {
|
|
||||||
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
|
|
||||||
v_flex()
|
|
||||||
.image_cache(self.image_cache.clone())
|
|
||||||
.relative()
|
|
||||||
.size_full()
|
|
||||||
.gap_10()
|
|
||||||
.items_center()
|
|
||||||
.justify_center()
|
|
||||||
.child(
|
|
||||||
v_flex()
|
|
||||||
.items_center()
|
|
||||||
.justify_center()
|
|
||||||
.gap_4()
|
|
||||||
.child(
|
|
||||||
svg()
|
|
||||||
.path("brand/coop.svg")
|
|
||||||
.size_16()
|
|
||||||
.text_color(cx.theme().elevated_surface_background),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.text_center()
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.text_xl()
|
|
||||||
.font_semibold()
|
|
||||||
.line_height(relative(1.3))
|
|
||||||
.child(shared_t!("welcome.title")),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.text_color(cx.theme().text_muted)
|
|
||||||
.child(shared_t!("welcome.subtitle")),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
v_flex()
|
|
||||||
.gap_2()
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.id("account")
|
|
||||||
.h_10()
|
|
||||||
.w_72()
|
|
||||||
.bg(cx.theme().elevated_surface_background)
|
|
||||||
.rounded_lg()
|
|
||||||
.text_sm()
|
|
||||||
.when(self.loading, |this| {
|
|
||||||
this.child(
|
|
||||||
div()
|
|
||||||
.size_full()
|
|
||||||
.flex()
|
|
||||||
.items_center()
|
|
||||||
.justify_center()
|
|
||||||
.child(Indicator::new().small()),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.when(!self.loading, |this| {
|
|
||||||
let avatar = self.profile.avatar(true);
|
|
||||||
let name = self.profile.display_name();
|
|
||||||
|
|
||||||
this.child(
|
|
||||||
h_flex()
|
|
||||||
.h_full()
|
|
||||||
.justify_center()
|
|
||||||
.gap_2()
|
|
||||||
.child(
|
|
||||||
h_flex()
|
|
||||||
.gap_1()
|
|
||||||
.child(Avatar::new(avatar).size(rems(1.5)))
|
|
||||||
.child(div().pb_px().font_semibold().child(name)),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.when(self.is_bunker, |this| {
|
|
||||||
let label = SharedString::from("Nostr Connect");
|
|
||||||
|
|
||||||
this.child(
|
|
||||||
div()
|
|
||||||
.py_0p5()
|
|
||||||
.px_2()
|
|
||||||
.text_xs()
|
|
||||||
.bg(cx.theme().secondary_active)
|
|
||||||
.text_color(
|
|
||||||
cx.theme().secondary_foreground,
|
|
||||||
)
|
|
||||||
.rounded_full()
|
|
||||||
.child(label),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.when(self.is_extension, |this| {
|
|
||||||
let label = SharedString::from("Extension");
|
|
||||||
|
|
||||||
this.child(
|
|
||||||
div()
|
|
||||||
.py_0p5()
|
|
||||||
.px_2()
|
|
||||||
.text_xs()
|
|
||||||
.bg(cx.theme().secondary_active)
|
|
||||||
.text_color(
|
|
||||||
cx.theme().secondary_foreground,
|
|
||||||
)
|
|
||||||
.rounded_full()
|
|
||||||
.child(label),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.active(|this| this.bg(cx.theme().element_active))
|
|
||||||
.hover(|this| this.bg(cx.theme().element_hover))
|
|
||||||
.on_click(cx.listener(move |this, _e, window, cx| {
|
|
||||||
this.login(window, cx);
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
Button::new("logout")
|
|
||||||
.label(t!("user.sign_out"))
|
|
||||||
.ghost()
|
|
||||||
.on_click(cx.listener(move |this, _e, window, cx| {
|
|
||||||
this.logout(window, cx);
|
|
||||||
})),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,210 +0,0 @@
|
|||||||
use std::fs;
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use dirs::document_dir;
|
|
||||||
use gpui::prelude::FluentBuilder;
|
|
||||||
use gpui::{
|
|
||||||
div, AppContext, ClipboardItem, Context, Entity, Flatten, IntoElement, ParentElement, Render,
|
|
||||||
SharedString, Styled, Task, Window,
|
|
||||||
};
|
|
||||||
use i18n::{shared_t, t};
|
|
||||||
use nostr_sdk::prelude::*;
|
|
||||||
use theme::ActiveTheme;
|
|
||||||
use ui::button::{Button, ButtonVariants};
|
|
||||||
use ui::input::{InputState, TextInput};
|
|
||||||
use ui::{divider, h_flex, v_flex, Disableable, IconName, Sizable};
|
|
||||||
|
|
||||||
pub struct BackupKeys {
|
|
||||||
password: Entity<InputState>,
|
|
||||||
pubkey_input: Entity<InputState>,
|
|
||||||
secret_input: Entity<InputState>,
|
|
||||||
error: Option<SharedString>,
|
|
||||||
copied: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl BackupKeys {
|
|
||||||
pub fn new(keys: &Keys, window: &mut Window, cx: &mut Context<'_, Self>) -> Self {
|
|
||||||
let Ok(npub) = keys.public_key.to_bech32();
|
|
||||||
let Ok(nsec) = keys.secret_key().to_bech32();
|
|
||||||
|
|
||||||
let password = cx.new(|cx| InputState::new(window, cx).masked(true));
|
|
||||||
|
|
||||||
let pubkey_input = cx.new(|cx| {
|
|
||||||
InputState::new(window, cx)
|
|
||||||
.disabled(true)
|
|
||||||
.default_value(npub)
|
|
||||||
});
|
|
||||||
|
|
||||||
let secret_input = cx.new(|cx| {
|
|
||||||
InputState::new(window, cx)
|
|
||||||
.disabled(true)
|
|
||||||
.default_value(nsec)
|
|
||||||
});
|
|
||||||
|
|
||||||
Self {
|
|
||||||
password,
|
|
||||||
pubkey_input,
|
|
||||||
secret_input,
|
|
||||||
error: None,
|
|
||||||
copied: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn password(&self, cx: &Context<Self>) -> String {
|
|
||||||
self.password.read(cx).value().to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn backup(&mut self, window: &mut Window, cx: &mut Context<Self>) -> Option<Task<()>> {
|
|
||||||
let document_dir = document_dir().expect("Failed to get document directory");
|
|
||||||
let password = self.password.read(cx).value().to_string();
|
|
||||||
|
|
||||||
if password.is_empty() {
|
|
||||||
self.set_error(t!("login.password_is_required"), window, cx);
|
|
||||||
return None;
|
|
||||||
};
|
|
||||||
|
|
||||||
let path = cx.prompt_for_new_path(&document_dir, Some("My Nostr Account"));
|
|
||||||
let nsec = self.secret_input.read(cx).value().to_string();
|
|
||||||
|
|
||||||
Some(cx.spawn_in(window, async move |this, cx| {
|
|
||||||
match Flatten::flatten(path.await.map_err(|e| e.into())) {
|
|
||||||
Ok(Ok(Some(path))) => {
|
|
||||||
cx.update(|window, cx| {
|
|
||||||
if let Err(e) = fs::write(&path, nsec) {
|
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
this.set_error(e.to_string(), window, cx);
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
log::error!("Failed to save backup keys");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn copy_secret(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
||||||
let item = ClipboardItem::new_string(self.secret_input.read(cx).value().to_string());
|
|
||||||
cx.write_to_clipboard(item);
|
|
||||||
|
|
||||||
self.set_copied(true, window, cx);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_copied(&mut self, status: bool, window: &mut Window, cx: &mut Context<Self>) {
|
|
||||||
self.copied = status;
|
|
||||||
cx.notify();
|
|
||||||
|
|
||||||
// Reset the copied state after a delay
|
|
||||||
if status {
|
|
||||||
cx.spawn_in(window, async move |this, cx| {
|
|
||||||
cx.background_executor().timer(Duration::from_secs(2)).await;
|
|
||||||
cx.update(|window, cx| {
|
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
this.set_copied(false, window, cx);
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
})
|
|
||||||
.detach();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_error<E>(&mut self, error: E, window: &mut Window, cx: &mut Context<Self>)
|
|
||||||
where
|
|
||||||
E: Into<SharedString>,
|
|
||||||
{
|
|
||||||
self.error = Some(error.into());
|
|
||||||
cx.notify();
|
|
||||||
|
|
||||||
// Clear the error message after a delay
|
|
||||||
cx.spawn_in(window, async move |this, cx| {
|
|
||||||
cx.background_executor().timer(Duration::from_secs(2)).await;
|
|
||||||
cx.update(|_, cx| {
|
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
this.error = None;
|
|
||||||
cx.notify();
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
})
|
|
||||||
.detach();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Render for BackupKeys {
|
|
||||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
|
||||||
v_flex()
|
|
||||||
.gap_3()
|
|
||||||
.text_sm()
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.text_color(cx.theme().text_muted)
|
|
||||||
.child(shared_t!("new_account.backup_description")),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
v_flex()
|
|
||||||
.gap_1()
|
|
||||||
.child(shared_t!("common.pubkey"))
|
|
||||||
.child(TextInput::new(&self.pubkey_input).small())
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.text_xs()
|
|
||||||
.text_color(cx.theme().text_muted)
|
|
||||||
.child(shared_t!("new_account.backup_pubkey_note")),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.child(divider(cx))
|
|
||||||
.child(
|
|
||||||
v_flex()
|
|
||||||
.gap_1()
|
|
||||||
.child(shared_t!("common.secret"))
|
|
||||||
.child(
|
|
||||||
h_flex()
|
|
||||||
.gap_1()
|
|
||||||
.child(TextInput::new(&self.secret_input).small())
|
|
||||||
.child(
|
|
||||||
Button::new("copy")
|
|
||||||
.icon({
|
|
||||||
if self.copied {
|
|
||||||
IconName::CheckCircleFill
|
|
||||||
} else {
|
|
||||||
IconName::Copy
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.ghost()
|
|
||||||
.disabled(self.copied)
|
|
||||||
.on_click(cx.listener(move |this, _e, window, cx| {
|
|
||||||
this.copy_secret(window, cx);
|
|
||||||
})),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.text_xs()
|
|
||||||
.text_color(cx.theme().text_muted)
|
|
||||||
.child(shared_t!("new_account.backup_secret_note")),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.child(divider(cx))
|
|
||||||
.child(
|
|
||||||
v_flex()
|
|
||||||
.gap_1()
|
|
||||||
.child(shared_t!("login.set_password"))
|
|
||||||
.child(TextInput::new(&self.password).small())
|
|
||||||
.when_some(self.error.as_ref(), |this, error| {
|
|
||||||
this.child(
|
|
||||||
div()
|
|
||||||
.italic()
|
|
||||||
.text_xs()
|
|
||||||
.text_color(cx.theme().danger_foreground)
|
|
||||||
.child(error.clone()),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
use gpui::{
|
|
||||||
div, App, AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString,
|
|
||||||
Styled, Window,
|
|
||||||
};
|
|
||||||
use i18n::{shared_t, t};
|
|
||||||
use theme::ActiveTheme;
|
|
||||||
use ui::input::{InputState, TextInput};
|
|
||||||
use ui::{v_flex, Sizable};
|
|
||||||
|
|
||||||
pub fn init(subject: Option<String>, window: &mut Window, cx: &mut App) -> Entity<Subject> {
|
|
||||||
Subject::new(subject, window, cx)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct Subject {
|
|
||||||
input: Entity<InputState>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Subject {
|
|
||||||
pub fn new(subject: Option<String>, window: &mut Window, cx: &mut App) -> Entity<Self> {
|
|
||||||
let input = cx.new(|cx| {
|
|
||||||
let mut this = InputState::new(window, cx).placeholder(t!("subject.placeholder"));
|
|
||||||
if let Some(text) = subject.as_ref() {
|
|
||||||
this.set_value(text, window, cx);
|
|
||||||
}
|
|
||||||
this
|
|
||||||
});
|
|
||||||
|
|
||||||
cx.new(|_| Self { input })
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn new_subject(&self, cx: &App) -> String {
|
|
||||||
self.input.read(cx).value().to_string()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Render for Subject {
|
|
||||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
|
||||||
v_flex()
|
|
||||||
.gap_1()
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.text_sm()
|
|
||||||
.text_color(cx.theme().text_muted)
|
|
||||||
.child(shared_t!("subject.title")),
|
|
||||||
)
|
|
||||||
.child(TextInput::new(&self.input).small())
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.text_xs()
|
|
||||||
.italic()
|
|
||||||
.text_color(cx.theme().text_placeholder)
|
|
||||||
.child(shared_t!("subject.help_text")),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
use std::ops::Range;
|
use std::ops::Range;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use account::Account;
|
||||||
use anyhow::{anyhow, Error};
|
use anyhow::{anyhow, Error};
|
||||||
use common::display::{RenderedProfile, TextUtils};
|
use chat::{ChatRegistry, Room};
|
||||||
use common::nip05::nip05_profile;
|
use common::{nip05_profile, RenderedProfile, TextUtils, BOOTSTRAP_RELAYS};
|
||||||
use global::constants::BOOTSTRAP_RELAYS;
|
|
||||||
use global::{app_state, nostr_client};
|
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
div, px, relative, rems, uniform_list, App, AppContext, Context, Entity, InteractiveElement,
|
div, px, relative, rems, uniform_list, App, AppContext, Context, Entity, InteractiveElement,
|
||||||
@@ -15,10 +14,10 @@ use gpui::{
|
|||||||
use gpui_tokio::Tokio;
|
use gpui_tokio::Tokio;
|
||||||
use i18n::{shared_t, t};
|
use i18n::{shared_t, t};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use registry::room::Room;
|
use person::PersonRegistry;
|
||||||
use registry::Registry;
|
|
||||||
use settings::AppSettings;
|
use settings::AppSettings;
|
||||||
use smallvec::{smallvec, SmallVec};
|
use smallvec::{smallvec, SmallVec};
|
||||||
|
use state::NostrRegistry;
|
||||||
use theme::ActiveTheme;
|
use theme::ActiveTheme;
|
||||||
use ui::avatar::Avatar;
|
use ui::avatar::Avatar;
|
||||||
use ui::button::{Button, ButtonVariants};
|
use ui::button::{Button, ButtonVariants};
|
||||||
@@ -115,7 +114,10 @@ pub struct Compose {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Compose {
|
impl Compose {
|
||||||
pub fn new(window: &mut Window, cx: &mut Context<'_, Self>) -> Self {
|
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let client = nostr.read(cx).client();
|
||||||
|
|
||||||
let contacts = cx.new(|_| vec![]);
|
let contacts = cx.new(|_| vec![]);
|
||||||
let error_message = cx.new(|_| None);
|
let error_message = cx.new(|_| None);
|
||||||
|
|
||||||
@@ -129,7 +131,6 @@ impl Compose {
|
|||||||
let mut tasks = smallvec![];
|
let mut tasks = smallvec![];
|
||||||
|
|
||||||
let get_contacts: Task<Result<Vec<Contact>, Error>> = cx.background_spawn(async move {
|
let get_contacts: Task<Result<Vec<Contact>, Error>> = cx.background_spawn(async move {
|
||||||
let client = nostr_client();
|
|
||||||
let signer = client.signer().await?;
|
let signer = client.signer().await?;
|
||||||
let public_key = signer.get_public_key().await?;
|
let public_key = signer.get_public_key().await?;
|
||||||
let profiles = client.database().contacts(public_key).await?;
|
let profiles = client.database().contacts(public_key).await?;
|
||||||
@@ -194,14 +195,13 @@ impl Compose {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn request_metadata(public_key: PublicKey) -> Result<(), Error> {
|
async fn request_metadata(client: &Client, public_key: PublicKey) -> Result<(), Error> {
|
||||||
let client = nostr_client();
|
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
|
||||||
let app_state = app_state();
|
let kinds = vec![Kind::Metadata, Kind::ContactList];
|
||||||
let kinds = vec![Kind::Metadata, Kind::ContactList, Kind::RelayList];
|
|
||||||
let filter = Filter::new().author(public_key).kinds(kinds).limit(10);
|
let filter = Filter::new().author(public_key).kinds(kinds).limit(10);
|
||||||
|
|
||||||
client
|
client
|
||||||
.subscribe_to(BOOTSTRAP_RELAYS, filter, app_state.auto_close_opts)
|
.subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts))
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -218,11 +218,13 @@ impl Compose {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn push_contact(&mut self, contact: Contact, window: &mut Window, cx: &mut Context<Self>) {
|
fn push_contact(&mut self, contact: Contact, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let client = nostr.read(cx).client();
|
||||||
let pk = contact.public_key;
|
let pk = contact.public_key;
|
||||||
|
|
||||||
if !self.contacts.read(cx).iter().any(|c| c.public_key == pk) {
|
if !self.contacts.read(cx).iter().any(|c| c.public_key == pk) {
|
||||||
self._tasks.push(cx.background_spawn(async move {
|
self._tasks.push(cx.background_spawn(async move {
|
||||||
Self::request_metadata(pk).await.ok();
|
Self::request_metadata(&client, pk).await.ok();
|
||||||
}));
|
}));
|
||||||
|
|
||||||
cx.defer_in(window, |this, window, cx| {
|
cx.defer_in(window, |this, window, cx| {
|
||||||
@@ -310,7 +312,11 @@ impl Compose {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn submit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
fn submit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
let registry = Registry::global(cx);
|
let chat = ChatRegistry::global(cx);
|
||||||
|
|
||||||
|
let account = Account::global(cx);
|
||||||
|
let public_key = account.read(cx).public_key();
|
||||||
|
|
||||||
let receivers: Vec<PublicKey> = self.selected(cx);
|
let receivers: Vec<PublicKey> = self.selected(cx);
|
||||||
let subject_input = self.title_input.read(cx).value();
|
let subject_input = self.title_input.read(cx).value();
|
||||||
let subject = (!subject_input.is_empty()).then(|| subject_input.to_string());
|
let subject = (!subject_input.is_empty()).then(|| subject_input.to_string());
|
||||||
@@ -320,27 +326,12 @@ impl Compose {
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
cx.spawn_in(window, async move |this, cx| {
|
chat.update(cx, |this, cx| {
|
||||||
let result = Room::new(subject, receivers).await;
|
this.push_room(cx.new(|_| Room::new(subject, public_key, receivers)), cx);
|
||||||
|
|
||||||
this.update_in(cx, |this, window, cx| {
|
|
||||||
match result {
|
|
||||||
Ok(room) => {
|
|
||||||
registry.update(cx, |this, cx| {
|
|
||||||
this.push_room(cx.new(|_| room), cx);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
window.close_modal(cx);
|
window.close_modal(cx);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
|
||||||
this.set_error(e.to_string(), cx);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
})
|
|
||||||
.detach();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_error(&mut self, error: impl Into<SharedString>, cx: &mut Context<Self>) {
|
fn set_error(&mut self, error: impl Into<SharedString>, cx: &mut Context<Self>) {
|
||||||
// Unlock the user input
|
// Unlock the user input
|
||||||
@@ -371,7 +362,7 @@ impl Compose {
|
|||||||
|
|
||||||
fn list_items(&self, range: Range<usize>, cx: &Context<Self>) -> Vec<impl IntoElement> {
|
fn list_items(&self, range: Range<usize>, cx: &Context<Self>) -> Vec<impl IntoElement> {
|
||||||
let proxy = AppSettings::get_proxy_user_avatars(cx);
|
let proxy = AppSettings::get_proxy_user_avatars(cx);
|
||||||
let registry = Registry::read_global(cx);
|
let persons = PersonRegistry::global(cx);
|
||||||
let mut items = Vec::with_capacity(self.contacts.read(cx).len());
|
let mut items = Vec::with_capacity(self.contacts.read(cx).len());
|
||||||
|
|
||||||
for ix in range {
|
for ix in range {
|
||||||
@@ -380,7 +371,7 @@ impl Compose {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let public_key = contact.public_key;
|
let public_key = contact.public_key;
|
||||||
let profile = registry.get_person(&public_key, cx);
|
let profile = persons.read(cx).get_person(&public_key, cx);
|
||||||
|
|
||||||
items.push(
|
items.push(
|
||||||
h_flex()
|
h_flex()
|
||||||
|
|||||||
@@ -1,285 +0,0 @@
|
|||||||
use std::str::FromStr;
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use common::nip96::nip96_upload;
|
|
||||||
use global::nostr_client;
|
|
||||||
use gpui::prelude::FluentBuilder;
|
|
||||||
use gpui::{
|
|
||||||
div, img, App, AppContext, Context, Entity, Flatten, IntoElement, ParentElement,
|
|
||||||
PathPromptOptions, Render, SharedString, Styled, Task, Window,
|
|
||||||
};
|
|
||||||
use i18n::{shared_t, t};
|
|
||||||
use nostr_sdk::prelude::*;
|
|
||||||
use settings::AppSettings;
|
|
||||||
use smol::fs;
|
|
||||||
use theme::ActiveTheme;
|
|
||||||
use ui::button::{Button, ButtonVariants};
|
|
||||||
use ui::input::{InputState, TextInput};
|
|
||||||
use ui::{v_flex, Disableable, IconName, Sizable};
|
|
||||||
|
|
||||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<EditProfile> {
|
|
||||||
EditProfile::new(window, cx)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct EditProfile {
|
|
||||||
profile: Option<Metadata>,
|
|
||||||
name_input: Entity<InputState>,
|
|
||||||
avatar_input: Entity<InputState>,
|
|
||||||
bio_input: Entity<InputState>,
|
|
||||||
website_input: Entity<InputState>,
|
|
||||||
is_loading: bool,
|
|
||||||
is_submitting: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl EditProfile {
|
|
||||||
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
|
|
||||||
let name_input =
|
|
||||||
cx.new(|cx| InputState::new(window, cx).placeholder(t!("profile.placeholder_name")));
|
|
||||||
let avatar_input =
|
|
||||||
cx.new(|cx| InputState::new(window, cx).placeholder("https://example.com/avatar.jpg"));
|
|
||||||
let website_input =
|
|
||||||
cx.new(|cx| InputState::new(window, cx).placeholder("https://your-website.com"));
|
|
||||||
let bio_input = cx.new(|cx| {
|
|
||||||
InputState::new(window, cx)
|
|
||||||
.multi_line()
|
|
||||||
.placeholder(t!("profile.placeholder_bio"))
|
|
||||||
});
|
|
||||||
|
|
||||||
cx.new(|cx| {
|
|
||||||
let this = Self {
|
|
||||||
name_input,
|
|
||||||
avatar_input,
|
|
||||||
bio_input,
|
|
||||||
website_input,
|
|
||||||
profile: None,
|
|
||||||
is_loading: false,
|
|
||||||
is_submitting: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
let task: Task<Result<Option<Metadata>, Error>> = cx.background_spawn(async move {
|
|
||||||
let client = nostr_client();
|
|
||||||
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_in(window, async move |this, cx| {
|
|
||||||
if let Ok(Some(metadata)) = task.await {
|
|
||||||
this.update_in(cx, |this: &mut EditProfile, window, cx| {
|
|
||||||
this.avatar_input.update(cx, |this, cx| {
|
|
||||||
if let Some(avatar) = metadata.picture.as_ref() {
|
|
||||||
this.set_value(avatar, window, cx);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
this.bio_input.update(cx, |this, cx| {
|
|
||||||
if let Some(bio) = metadata.about.as_ref() {
|
|
||||||
this.set_value(bio, window, cx);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
this.name_input.update(cx, |this, cx| {
|
|
||||||
if let Some(display_name) = metadata.display_name.as_ref() {
|
|
||||||
this.set_value(display_name, window, cx);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
this.website_input.update(cx, |this, cx| {
|
|
||||||
if let Some(website) = metadata.website.as_ref() {
|
|
||||||
this.set_value(website, window, cx);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
this.profile = Some(metadata);
|
|
||||||
cx.notify();
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.detach();
|
|
||||||
|
|
||||||
this
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn upload(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
||||||
let nip96 = AppSettings::get_media_server(cx);
|
|
||||||
let avatar_input = self.avatar_input.downgrade();
|
|
||||||
let paths = cx.prompt_for_paths(PathPromptOptions {
|
|
||||||
files: true,
|
|
||||||
directories: false,
|
|
||||||
multiple: false,
|
|
||||||
prompt: None,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Show loading spinner
|
|
||||||
self.set_loading(true, cx);
|
|
||||||
|
|
||||||
cx.spawn_in(window, async move |this, cx| {
|
|
||||||
match Flatten::flatten(paths.await.map_err(|e| e.into())) {
|
|
||||||
Ok(Some(mut paths)) => {
|
|
||||||
let path = paths.pop().unwrap();
|
|
||||||
|
|
||||||
if let Ok(file_data) = fs::read(path).await {
|
|
||||||
let (tx, rx) = oneshot::channel::<Url>();
|
|
||||||
|
|
||||||
nostr_sdk::async_utility::task::spawn(async move {
|
|
||||||
if let Ok(url) = nip96_upload(nostr_client(), &nip96, file_data).await {
|
|
||||||
_ = tx.send(url);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if let Ok(url) = rx.await {
|
|
||||||
cx.update(|window, cx| {
|
|
||||||
// Stop loading spinner
|
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
this.set_loading(false, cx);
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
|
|
||||||
// Set avatar input
|
|
||||||
avatar_input
|
|
||||||
.update(cx, |this, cx| {
|
|
||||||
this.set_value(url.to_string(), window, cx);
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(None) => {
|
|
||||||
cx.update(|_, cx| {
|
|
||||||
// Stop loading spinner
|
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
this.set_loading(false, cx);
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
}
|
|
||||||
Err(_) => {}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.detach();
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn set_metadata(&mut self, cx: &mut Context<Self>) -> Task<Result<Option<Profile>, Error>> {
|
|
||||||
let avatar = self.avatar_input.read(cx).value().to_string();
|
|
||||||
let name = self.name_input.read(cx).value().to_string();
|
|
||||||
let bio = self.bio_input.read(cx).value().to_string();
|
|
||||||
let website = self.website_input.read(cx).value().to_string();
|
|
||||||
|
|
||||||
let old_metadata = if let Some(metadata) = self.profile.as_ref() {
|
|
||||||
metadata.clone()
|
|
||||||
} else {
|
|
||||||
Metadata::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut new_metadata = old_metadata.display_name(name).about(bio);
|
|
||||||
|
|
||||||
if let Ok(url) = Url::from_str(&avatar) {
|
|
||||||
new_metadata = new_metadata.picture(url);
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Ok(url) = Url::from_str(&website) {
|
|
||||||
new_metadata = new_metadata.website(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
cx.background_spawn(async move {
|
|
||||||
let client = nostr_client();
|
|
||||||
let output = client.set_metadata(&new_metadata).await?;
|
|
||||||
let event = client
|
|
||||||
.database()
|
|
||||||
.event_by_id(&output.val)
|
|
||||||
.await?
|
|
||||||
.map(|event| {
|
|
||||||
let metadata = Metadata::from_json(&event.content).unwrap_or_default();
|
|
||||||
Profile::new(event.pubkey, metadata)
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(event)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
|
|
||||||
self.is_loading = status;
|
|
||||||
cx.notify();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Render for EditProfile {
|
|
||||||
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
|
|
||||||
v_flex()
|
|
||||||
.gap_3()
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.w_full()
|
|
||||||
.h_32()
|
|
||||||
.bg(cx.theme().surface_background)
|
|
||||||
.rounded(cx.theme().radius)
|
|
||||||
.flex()
|
|
||||||
.flex_col()
|
|
||||||
.items_center()
|
|
||||||
.justify_center()
|
|
||||||
.gap_2()
|
|
||||||
.map(|this| {
|
|
||||||
let picture = self.avatar_input.read(cx).value();
|
|
||||||
if picture.is_empty() {
|
|
||||||
this.child(
|
|
||||||
img("brand/avatar.png")
|
|
||||||
.rounded_full()
|
|
||||||
.size_10()
|
|
||||||
.flex_shrink_0(),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
this.child(
|
|
||||||
img(picture.clone())
|
|
||||||
.rounded_full()
|
|
||||||
.size_10()
|
|
||||||
.flex_shrink_0(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.child(
|
|
||||||
Button::new("upload")
|
|
||||||
.icon(IconName::Upload)
|
|
||||||
.label(t!("common.change"))
|
|
||||||
.ghost()
|
|
||||||
.small()
|
|
||||||
.disabled(self.is_loading || self.is_submitting)
|
|
||||||
.loading(self.is_loading)
|
|
||||||
.on_click(cx.listener(move |this, _, window, cx| {
|
|
||||||
this.upload(window, cx);
|
|
||||||
})),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.flex()
|
|
||||||
.flex_col()
|
|
||||||
.gap_1()
|
|
||||||
.text_sm()
|
|
||||||
.child(shared_t!("profile.label_name"))
|
|
||||||
.child(TextInput::new(&self.name_input).small()),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.flex()
|
|
||||||
.flex_col()
|
|
||||||
.gap_1()
|
|
||||||
.text_sm()
|
|
||||||
.child(shared_t!("profile.label_website"))
|
|
||||||
.child(TextInput::new(&self.website_input).small()),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.flex()
|
|
||||||
.flex_col()
|
|
||||||
.gap_1()
|
|
||||||
.text_sm()
|
|
||||||
.child(shared_t!("profile.label_bio"))
|
|
||||||
.child(TextInput::new(&self.bio_input).small()),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,528 +0,0 @@
|
|||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use client_keys::ClientKeys;
|
|
||||||
use global::constants::{ACCOUNT_IDENTIFIER, BUNKER_TIMEOUT};
|
|
||||||
use global::nostr_client;
|
|
||||||
use gpui::prelude::FluentBuilder;
|
|
||||||
use gpui::{
|
|
||||||
div, relative, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle,
|
|
||||||
Focusable, IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Task,
|
|
||||||
Window,
|
|
||||||
};
|
|
||||||
use i18n::{shared_t, t};
|
|
||||||
use nostr_connect::prelude::*;
|
|
||||||
use smallvec::{smallvec, SmallVec};
|
|
||||||
use theme::ActiveTheme;
|
|
||||||
use ui::button::{Button, ButtonVariants};
|
|
||||||
use ui::dock_area::panel::{Panel, PanelEvent};
|
|
||||||
use ui::input::{InputEvent, InputState, TextInput};
|
|
||||||
use ui::popup_menu::PopupMenu;
|
|
||||||
use ui::{v_flex, ContextModal, Disableable, Sizable, StyledExt};
|
|
||||||
|
|
||||||
use crate::actions::CoopAuthUrlHandler;
|
|
||||||
|
|
||||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Login> {
|
|
||||||
Login::new(window, cx)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct Login {
|
|
||||||
input: Entity<InputState>,
|
|
||||||
error: Entity<Option<SharedString>>,
|
|
||||||
countdown: Entity<Option<u64>>,
|
|
||||||
logging_in: bool,
|
|
||||||
// Panel
|
|
||||||
name: SharedString,
|
|
||||||
focus_handle: FocusHandle,
|
|
||||||
#[allow(unused)]
|
|
||||||
subscriptions: SmallVec<[Subscription; 1]>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Login {
|
|
||||||
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
|
|
||||||
cx.new(|cx| Self::view(window, cx))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn view(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
|
||||||
let input = cx.new(|cx| InputState::new(window, cx).placeholder("nsec... or bunker://..."));
|
|
||||||
let error = cx.new(|_| None);
|
|
||||||
let countdown = cx.new(|_| None);
|
|
||||||
|
|
||||||
let mut subscriptions = smallvec![];
|
|
||||||
|
|
||||||
// Subscribe to key input events and process login when the user presses enter
|
|
||||||
subscriptions.push(
|
|
||||||
cx.subscribe_in(&input, window, |this, _e, event, window, cx| {
|
|
||||||
if let InputEvent::PressEnter { .. } = event {
|
|
||||||
this.login(window, cx);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
Self {
|
|
||||||
input,
|
|
||||||
error,
|
|
||||||
countdown,
|
|
||||||
subscriptions,
|
|
||||||
name: "Login".into(),
|
|
||||||
focus_handle: cx.focus_handle(),
|
|
||||||
logging_in: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn login(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
||||||
if self.logging_in {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Prevent duplicate login requests
|
|
||||||
self.set_logging_in(true, cx);
|
|
||||||
|
|
||||||
// Disable the input
|
|
||||||
self.input.update(cx, |this, cx| {
|
|
||||||
this.set_loading(true, cx);
|
|
||||||
this.set_disabled(true, cx);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Content can be secret key or bunker://
|
|
||||||
match self.input.read(cx).value().to_string() {
|
|
||||||
s if s.starts_with("nsec1") => self.ask_for_password(s, window, cx),
|
|
||||||
s if s.starts_with("ncryptsec1") => self.ask_for_password(s, window, cx),
|
|
||||||
s if s.starts_with("bunker://") => self.login_with_bunker(s, window, cx),
|
|
||||||
_ => self.set_error(t!("login.invalid_key"), window, cx),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ask_for_password(&mut self, content: String, window: &mut Window, cx: &mut Context<Self>) {
|
|
||||||
let current_view = cx.entity().downgrade();
|
|
||||||
let is_ncryptsec = content.starts_with("ncryptsec1");
|
|
||||||
|
|
||||||
let pwd_input = cx.new(|cx| InputState::new(window, cx).masked(true));
|
|
||||||
let weak_pwd_input = pwd_input.downgrade();
|
|
||||||
|
|
||||||
let confirm_input = cx.new(|cx| InputState::new(window, cx).masked(true));
|
|
||||||
let weak_confirm_input = confirm_input.downgrade();
|
|
||||||
|
|
||||||
window.open_modal(cx, move |this, _window, cx| {
|
|
||||||
let weak_pwd_input = weak_pwd_input.clone();
|
|
||||||
let weak_confirm_input = weak_confirm_input.clone();
|
|
||||||
|
|
||||||
let view_cancel = current_view.clone();
|
|
||||||
let view_ok = current_view.clone();
|
|
||||||
|
|
||||||
let label: SharedString = if !is_ncryptsec {
|
|
||||||
t!("login.set_password").into()
|
|
||||||
} else {
|
|
||||||
t!("login.password_to_decrypt").into()
|
|
||||||
};
|
|
||||||
|
|
||||||
let description: SharedString = if is_ncryptsec {
|
|
||||||
t!("login.password_description").into()
|
|
||||||
} else {
|
|
||||||
t!("login.password_description_full").into()
|
|
||||||
};
|
|
||||||
|
|
||||||
this.overlay_closable(false)
|
|
||||||
.show_close(false)
|
|
||||||
.keyboard(false)
|
|
||||||
.confirm()
|
|
||||||
.on_cancel(move |_, window, cx| {
|
|
||||||
view_cancel
|
|
||||||
.update(cx, |this, cx| {
|
|
||||||
this.set_error(t!("login.password_is_required"), window, cx);
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
true
|
|
||||||
})
|
|
||||||
.on_ok(move |_, window, cx| {
|
|
||||||
let value = weak_pwd_input
|
|
||||||
.read_with(cx, |state, _cx| state.value().to_owned())
|
|
||||||
.ok();
|
|
||||||
|
|
||||||
let confirm = weak_confirm_input
|
|
||||||
.read_with(cx, |state, _cx| state.value().to_owned())
|
|
||||||
.ok();
|
|
||||||
|
|
||||||
view_ok
|
|
||||||
.update(cx, |this, cx| {
|
|
||||||
this.verify_password(value, confirm, is_ncryptsec, window, cx);
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
true
|
|
||||||
})
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.w_full()
|
|
||||||
.flex()
|
|
||||||
.flex_col()
|
|
||||||
.gap_2()
|
|
||||||
.text_sm()
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.flex()
|
|
||||||
.flex_col()
|
|
||||||
.gap_1()
|
|
||||||
.child(label)
|
|
||||||
.child(TextInput::new(&pwd_input).small()),
|
|
||||||
)
|
|
||||||
.when(content.starts_with("nsec1"), |this| {
|
|
||||||
this.child(
|
|
||||||
div()
|
|
||||||
.flex()
|
|
||||||
.flex_col()
|
|
||||||
.gap_1()
|
|
||||||
.child(SharedString::new(t!("login.confirm_password")))
|
|
||||||
.child(TextInput::new(&confirm_input).small()),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.text_xs()
|
|
||||||
.italic()
|
|
||||||
.text_color(cx.theme().text_placeholder)
|
|
||||||
.child(description),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn verify_password(
|
|
||||||
&mut self,
|
|
||||||
password: Option<SharedString>,
|
|
||||||
confirm: Option<SharedString>,
|
|
||||||
is_ncryptsec: bool,
|
|
||||||
window: &mut Window,
|
|
||||||
cx: &mut Context<Self>,
|
|
||||||
) {
|
|
||||||
let Some(password) = password else {
|
|
||||||
self.set_error(t!("login.password_is_required"), window, cx);
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
if password.is_empty() {
|
|
||||||
self.set_error(t!("login.password_is_required"), window, cx);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip verification if key is ncryptsec
|
|
||||||
if is_ncryptsec {
|
|
||||||
self.login_with_keys(password.to_string(), window, cx);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(confirm) = confirm else {
|
|
||||||
self.set_error(t!("login.must_confirm_password"), window, cx);
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
if confirm.is_empty() {
|
|
||||||
self.set_error(t!("login.must_confirm_password"), window, cx);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if password != confirm {
|
|
||||||
self.set_error(t!("login.password_not_match"), window, cx);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
self.login_with_keys(password.to_string(), window, cx);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn login_with_keys(&mut self, password: String, window: &mut Window, cx: &mut Context<Self>) {
|
|
||||||
let value = self.input.read(cx).value().to_string();
|
|
||||||
|
|
||||||
let secret_key = if value.starts_with("nsec1") {
|
|
||||||
SecretKey::parse(&value).ok()
|
|
||||||
} else if value.starts_with("ncryptsec1") {
|
|
||||||
EncryptedSecretKey::from_bech32(&value)
|
|
||||||
.map(|enc| enc.decrypt(&password).ok())
|
|
||||||
.unwrap_or_default()
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Some(secret_key) = secret_key {
|
|
||||||
let keys = Keys::new(secret_key);
|
|
||||||
|
|
||||||
// Encrypt and save user secret key to disk
|
|
||||||
self.write_keys_to_disk(&keys, password, cx);
|
|
||||||
|
|
||||||
// Set the client's signer with the current keys
|
|
||||||
cx.background_spawn(async move {
|
|
||||||
let client = nostr_client();
|
|
||||||
client.set_signer(keys).await;
|
|
||||||
})
|
|
||||||
.detach();
|
|
||||||
} else {
|
|
||||||
self.set_error(t!("login.key_invalid"), window, cx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn login_with_bunker(&mut self, content: String, window: &mut Window, cx: &mut Context<Self>) {
|
|
||||||
let Ok(uri) = NostrConnectURI::parse(content) else {
|
|
||||||
self.set_error(t!("login.bunker_invalid"), window, cx);
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
let client_keys = ClientKeys::global(cx);
|
|
||||||
let app_keys = client_keys.read(cx).keys();
|
|
||||||
|
|
||||||
let timeout = Duration::from_secs(BUNKER_TIMEOUT);
|
|
||||||
let mut signer = NostrConnect::new(uri, app_keys, timeout, None).unwrap();
|
|
||||||
|
|
||||||
// Handle auth url with the default browser
|
|
||||||
signer.auth_url_handler(CoopAuthUrlHandler);
|
|
||||||
|
|
||||||
// Start countdown
|
|
||||||
cx.spawn_in(window, async move |this, cx| {
|
|
||||||
for i in (0..=BUNKER_TIMEOUT).rev() {
|
|
||||||
if i == 0 {
|
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
this.set_countdown(None, cx);
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
} else {
|
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
this.set_countdown(Some(i), cx);
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
}
|
|
||||||
cx.background_executor().timer(Duration::from_secs(1)).await;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.detach();
|
|
||||||
|
|
||||||
// Handle connection
|
|
||||||
cx.spawn_in(window, async move |this, cx| {
|
|
||||||
match signer.bunker_uri().await {
|
|
||||||
Ok(uri) => {
|
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
this.write_uri_to_disk(signer, uri, cx);
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
}
|
|
||||||
Err(error) => {
|
|
||||||
this.update_in(cx, |this, window, cx| {
|
|
||||||
this.set_error(error.to_string(), window, cx);
|
|
||||||
// Force reset the client keys
|
|
||||||
//
|
|
||||||
// This step is necessary to ensure that user can retry the connection
|
|
||||||
client_keys.update(cx, |this, cx| {
|
|
||||||
this.force_new_keys(cx);
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.detach();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_uri_to_disk(
|
|
||||||
&mut self,
|
|
||||||
signer: NostrConnect,
|
|
||||||
uri: NostrConnectURI,
|
|
||||||
cx: &mut Context<Self>,
|
|
||||||
) {
|
|
||||||
let mut uri_without_secret = uri.to_string();
|
|
||||||
|
|
||||||
// Clear the secret parameter in the URI if it exists
|
|
||||||
if let Some(secret) = uri.secret() {
|
|
||||||
uri_without_secret = uri_without_secret.replace(secret, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
let task: Task<Result<(), anyhow::Error>> = cx.background_spawn(async move {
|
|
||||||
let client = nostr_client();
|
|
||||||
|
|
||||||
// Update the client's signer
|
|
||||||
client.set_signer(signer).await;
|
|
||||||
|
|
||||||
let signer = client.signer().await?;
|
|
||||||
let public_key = signer.get_public_key().await?;
|
|
||||||
|
|
||||||
let event = EventBuilder::new(Kind::ApplicationSpecificData, uri_without_secret)
|
|
||||||
.tags(vec![Tag::identifier(ACCOUNT_IDENTIFIER)])
|
|
||||||
.build(public_key)
|
|
||||||
.sign(&Keys::generate())
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
// Save the event to the database
|
|
||||||
client.database().save_event(&event).await?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
});
|
|
||||||
|
|
||||||
task.detach();
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn write_keys_to_disk(&self, keys: &Keys, password: String, cx: &mut Context<Self>) {
|
|
||||||
let keys = keys.to_owned();
|
|
||||||
let public_key = keys.public_key();
|
|
||||||
|
|
||||||
cx.background_spawn(async move {
|
|
||||||
if let Ok(enc_key) =
|
|
||||||
EncryptedSecretKey::new(keys.secret_key(), &password, 8, KeySecurity::Unknown)
|
|
||||||
{
|
|
||||||
let client = nostr_client();
|
|
||||||
let value = enc_key.to_bech32().unwrap();
|
|
||||||
let keys = Keys::generate();
|
|
||||||
let tags = vec![Tag::identifier(ACCOUNT_IDENTIFIER)];
|
|
||||||
let kind = Kind::ApplicationSpecificData;
|
|
||||||
|
|
||||||
let builder = EventBuilder::new(kind, value)
|
|
||||||
.tags(tags)
|
|
||||||
.build(public_key)
|
|
||||||
.sign(&keys)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
if let Ok(event) = builder {
|
|
||||||
if let Err(e) = client.database().save_event(&event).await {
|
|
||||||
log::error!("Failed to save event: {e}");
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.detach();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_error(
|
|
||||||
&mut self,
|
|
||||||
message: impl Into<SharedString>,
|
|
||||||
window: &mut Window,
|
|
||||||
cx: &mut Context<Self>,
|
|
||||||
) {
|
|
||||||
// Reset the log in state
|
|
||||||
self.set_logging_in(false, cx);
|
|
||||||
|
|
||||||
// Reset the countdown
|
|
||||||
self.set_countdown(None, cx);
|
|
||||||
|
|
||||||
// Update error message
|
|
||||||
self.error.update(cx, |this, cx| {
|
|
||||||
*this = Some(message.into());
|
|
||||||
cx.notify();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Re enable the input
|
|
||||||
self.input.update(cx, |this, cx| {
|
|
||||||
this.set_value("", window, cx);
|
|
||||||
this.set_loading(false, cx);
|
|
||||||
this.set_disabled(false, cx);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Clear the error message after 3 secs
|
|
||||||
cx.spawn(async move |this, cx| {
|
|
||||||
cx.background_executor().timer(Duration::from_secs(3)).await;
|
|
||||||
|
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
this.error.update(cx, |this, cx| {
|
|
||||||
*this = None;
|
|
||||||
cx.notify();
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
})
|
|
||||||
.detach();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_logging_in(&mut self, status: bool, cx: &mut Context<Self>) {
|
|
||||||
self.logging_in = status;
|
|
||||||
cx.notify();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_countdown(&mut self, i: Option<u64>, cx: &mut Context<Self>) {
|
|
||||||
self.countdown.update(cx, |this, cx| {
|
|
||||||
*this = i;
|
|
||||||
cx.notify();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Panel for Login {
|
|
||||||
fn panel_id(&self) -> SharedString {
|
|
||||||
self.name.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn title(&self, _cx: &App) -> AnyElement {
|
|
||||||
self.name.clone().into_any_element()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn popup_menu(&self, menu: PopupMenu, _cx: &App) -> PopupMenu {
|
|
||||||
menu.track_focus(&self.focus_handle)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn toolbar_buttons(&self, _window: &Window, _cx: &App) -> Vec<Button> {
|
|
||||||
vec![]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl EventEmitter<PanelEvent> for Login {}
|
|
||||||
|
|
||||||
impl Focusable for Login {
|
|
||||||
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
|
|
||||||
self.focus_handle.clone()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Render for Login {
|
|
||||||
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
|
|
||||||
v_flex()
|
|
||||||
.relative()
|
|
||||||
.size_full()
|
|
||||||
.items_center()
|
|
||||||
.justify_center()
|
|
||||||
.child(
|
|
||||||
v_flex()
|
|
||||||
.w_96()
|
|
||||||
.gap_10()
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.text_center()
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.text_xl()
|
|
||||||
.font_semibold()
|
|
||||||
.line_height(relative(1.3))
|
|
||||||
.child(shared_t!("login.title")),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.text_color(cx.theme().text_muted)
|
|
||||||
.child(shared_t!("login.key_description")),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
v_flex()
|
|
||||||
.gap_3()
|
|
||||||
.child(TextInput::new(&self.input))
|
|
||||||
.child(
|
|
||||||
Button::new("login")
|
|
||||||
.label(t!("common.continue"))
|
|
||||||
.primary()
|
|
||||||
.loading(self.logging_in)
|
|
||||||
.disabled(self.logging_in)
|
|
||||||
.on_click(cx.listener(move |this, _, window, cx| {
|
|
||||||
this.login(window, cx);
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
.when_some(self.countdown.read(cx).as_ref(), |this, i| {
|
|
||||||
this.child(
|
|
||||||
div()
|
|
||||||
.text_xs()
|
|
||||||
.text_center()
|
|
||||||
.text_color(cx.theme().text_muted)
|
|
||||||
.child(shared_t!("login.approve_message", i = i)),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.when_some(self.error.read(cx).clone(), |this, error| {
|
|
||||||
this.child(
|
|
||||||
div()
|
|
||||||
.text_xs()
|
|
||||||
.text_center()
|
|
||||||
.text_color(cx.theme().danger_foreground)
|
|
||||||
.child(error),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,14 +1,7 @@
|
|||||||
pub mod account;
|
|
||||||
pub mod backup_keys;
|
|
||||||
pub mod chat;
|
|
||||||
pub mod compose;
|
pub mod compose;
|
||||||
pub mod edit_profile;
|
|
||||||
pub mod login;
|
|
||||||
pub mod new_account;
|
|
||||||
pub mod onboarding;
|
pub mod onboarding;
|
||||||
pub mod preferences;
|
pub mod preferences;
|
||||||
pub mod screening;
|
pub mod screening;
|
||||||
pub mod setup_relay;
|
pub mod setup_relay;
|
||||||
pub mod sidebar;
|
pub mod startup;
|
||||||
pub mod user_profile;
|
|
||||||
pub mod welcome;
|
pub mod welcome;
|
||||||
|
|||||||
@@ -1,377 +0,0 @@
|
|||||||
use anyhow::anyhow;
|
|
||||||
use common::nip96::nip96_upload;
|
|
||||||
use global::constants::{ACCOUNT_IDENTIFIER, NIP17_RELAYS, NIP65_RELAYS};
|
|
||||||
use global::nostr_client;
|
|
||||||
use gpui::{
|
|
||||||
div, relative, rems, AnyElement, App, AppContext, AsyncWindowContext, Context, Entity,
|
|
||||||
EventEmitter, Flatten, FocusHandle, Focusable, IntoElement, ParentElement, PathPromptOptions,
|
|
||||||
Render, SharedString, Styled, WeakEntity, Window,
|
|
||||||
};
|
|
||||||
use gpui_tokio::Tokio;
|
|
||||||
use i18n::{shared_t, t};
|
|
||||||
use nostr_sdk::prelude::*;
|
|
||||||
use settings::AppSettings;
|
|
||||||
use smol::fs;
|
|
||||||
use theme::ActiveTheme;
|
|
||||||
use ui::avatar::Avatar;
|
|
||||||
use ui::button::{Button, ButtonVariants};
|
|
||||||
use ui::dock_area::panel::{Panel, PanelEvent};
|
|
||||||
use ui::input::{InputState, TextInput};
|
|
||||||
use ui::modal::ModalButtonProps;
|
|
||||||
use ui::popup_menu::PopupMenu;
|
|
||||||
use ui::{divider, v_flex, ContextModal, Disableable, IconName, Sizable, StyledExt};
|
|
||||||
|
|
||||||
use crate::views::backup_keys::BackupKeys;
|
|
||||||
|
|
||||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<NewAccount> {
|
|
||||||
NewAccount::new(window, cx)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct NewAccount {
|
|
||||||
name_input: Entity<InputState>,
|
|
||||||
avatar_input: Entity<InputState>,
|
|
||||||
temp_keys: Entity<Keys>,
|
|
||||||
uploading: bool,
|
|
||||||
submitting: bool,
|
|
||||||
// Panel
|
|
||||||
name: SharedString,
|
|
||||||
focus_handle: FocusHandle,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl NewAccount {
|
|
||||||
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
|
|
||||||
cx.new(|cx| Self::view(window, cx))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn view(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
|
||||||
let temp_keys = cx.new(|_| Keys::generate());
|
|
||||||
let name_input = cx.new(|cx| InputState::new(window, cx).placeholder("Alice"));
|
|
||||||
let avatar_input = cx.new(|cx| InputState::new(window, cx));
|
|
||||||
|
|
||||||
Self {
|
|
||||||
name_input,
|
|
||||||
avatar_input,
|
|
||||||
temp_keys,
|
|
||||||
uploading: false,
|
|
||||||
submitting: false,
|
|
||||||
name: "New Account".into(),
|
|
||||||
focus_handle: cx.focus_handle(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn create(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
||||||
self.submitting(true, cx);
|
|
||||||
|
|
||||||
let keys = self.temp_keys.read(cx).clone();
|
|
||||||
let view = cx.new(|cx| BackupKeys::new(&keys, window, cx));
|
|
||||||
let weak_view = view.downgrade();
|
|
||||||
let current_view = cx.entity().downgrade();
|
|
||||||
|
|
||||||
window.open_modal(cx, move |modal, _window, _cx| {
|
|
||||||
let weak_view = weak_view.clone();
|
|
||||||
let current_view = current_view.clone();
|
|
||||||
|
|
||||||
modal
|
|
||||||
.alert()
|
|
||||||
.title(shared_t!("new_account.backup_label"))
|
|
||||||
.child(view.clone())
|
|
||||||
.button_props(
|
|
||||||
ModalButtonProps::default().ok_text(t!("new_account.backup_download")),
|
|
||||||
)
|
|
||||||
.on_ok(move |_, window, cx| {
|
|
||||||
weak_view
|
|
||||||
.update(cx, |this, cx| {
|
|
||||||
let password = this.password(cx);
|
|
||||||
let current_view = current_view.clone();
|
|
||||||
|
|
||||||
if let Some(task) = this.backup(window, cx) {
|
|
||||||
cx.spawn_in(window, async move |_, cx| {
|
|
||||||
task.await;
|
|
||||||
|
|
||||||
cx.update(|window, cx| {
|
|
||||||
current_view
|
|
||||||
.update(cx, |this, cx| {
|
|
||||||
this.set_signer(password, window, cx);
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
})
|
|
||||||
.ok()
|
|
||||||
})
|
|
||||||
.detach();
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
// true to close the modal
|
|
||||||
false
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_signer(&mut self, password: String, window: &mut Window, cx: &mut Context<Self>) {
|
|
||||||
window.close_modal(cx);
|
|
||||||
|
|
||||||
let keys = self.temp_keys.read(cx).clone();
|
|
||||||
let avatar = self.avatar_input.read(cx).value().to_string();
|
|
||||||
let name = self.name_input.read(cx).value().to_string();
|
|
||||||
let mut metadata = Metadata::new().display_name(name.clone()).name(name);
|
|
||||||
|
|
||||||
if let Ok(url) = Url::parse(&avatar) {
|
|
||||||
metadata = metadata.picture(url);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Encrypt and save user secret key to disk
|
|
||||||
self.write_keys_to_disk(&keys, password, cx);
|
|
||||||
|
|
||||||
// Set the client's signer with the current keys
|
|
||||||
cx.background_spawn(async move {
|
|
||||||
let client = nostr_client();
|
|
||||||
|
|
||||||
// Set the client's signer with the current keys
|
|
||||||
client.set_signer(keys).await;
|
|
||||||
|
|
||||||
// Set metadata
|
|
||||||
if let Err(e) = client.set_metadata(&metadata).await {
|
|
||||||
log::error!("Failed to set metadata: {e}");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set NIP-65 relays
|
|
||||||
let builder = EventBuilder::new(Kind::RelayList, "").tags(
|
|
||||||
NIP65_RELAYS.into_iter().filter_map(|url| {
|
|
||||||
if let Ok(url) = RelayUrl::parse(url) {
|
|
||||||
Some(Tag::relay_metadata(url, None))
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
if let Err(e) = client.send_event_builder(builder).await {
|
|
||||||
log::error!("Failed to send NIP-65 relay list event: {e}");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set NIP-17 relays
|
|
||||||
let builder = EventBuilder::new(Kind::InboxRelays, "").tags(
|
|
||||||
NIP17_RELAYS.into_iter().filter_map(|url| {
|
|
||||||
if let Ok(url) = RelayUrl::parse(url) {
|
|
||||||
Some(Tag::relay(url))
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
if let Err(e) = client.send_event_builder(builder).await {
|
|
||||||
log::error!("Failed to send messaging relay list event: {e}");
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.detach();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_keys_to_disk(&self, keys: &Keys, password: String, cx: &mut Context<Self>) {
|
|
||||||
let keys = keys.to_owned();
|
|
||||||
let public_key = keys.public_key();
|
|
||||||
|
|
||||||
cx.background_spawn(async move {
|
|
||||||
if let Ok(enc_key) =
|
|
||||||
EncryptedSecretKey::new(keys.secret_key(), &password, 8, KeySecurity::Unknown)
|
|
||||||
{
|
|
||||||
let client = nostr_client();
|
|
||||||
let value = enc_key.to_bech32().unwrap();
|
|
||||||
let keys = Keys::generate();
|
|
||||||
let tags = vec![Tag::identifier(ACCOUNT_IDENTIFIER)];
|
|
||||||
let kind = Kind::ApplicationSpecificData;
|
|
||||||
|
|
||||||
let builder = EventBuilder::new(kind, value)
|
|
||||||
.tags(tags)
|
|
||||||
.build(public_key)
|
|
||||||
.sign(&keys)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
if let Ok(event) = builder {
|
|
||||||
if let Err(e) = client.database().save_event(&event).await {
|
|
||||||
log::error!("Failed to save event: {e}");
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.detach();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn upload(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
||||||
self.uploading(true, cx);
|
|
||||||
|
|
||||||
// Get the user's configured NIP96 server
|
|
||||||
let nip96_server = AppSettings::get_media_server(cx);
|
|
||||||
|
|
||||||
// Open native file dialog
|
|
||||||
let paths = cx.prompt_for_paths(PathPromptOptions {
|
|
||||||
files: true,
|
|
||||||
directories: false,
|
|
||||||
multiple: false,
|
|
||||||
prompt: None,
|
|
||||||
});
|
|
||||||
|
|
||||||
let task = Tokio::spawn(cx, async move {
|
|
||||||
match Flatten::flatten(paths.await.map_err(|e| e.into())) {
|
|
||||||
Ok(Some(mut paths)) => {
|
|
||||||
if let Some(path) = paths.pop() {
|
|
||||||
let file = fs::read(path).await?;
|
|
||||||
let url = nip96_upload(nostr_client(), &nip96_server, file).await?;
|
|
||||||
|
|
||||||
Ok(url)
|
|
||||||
} else {
|
|
||||||
Err(anyhow!("Path not found"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(None) => Err(anyhow!("User cancelled")),
|
|
||||||
Err(e) => Err(anyhow!("File dialog error: {e}")),
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
cx.spawn_in(window, async move |this, cx| {
|
|
||||||
match Flatten::flatten(task.await.map_err(|e| e.into())) {
|
|
||||||
Ok(Ok(url)) => {
|
|
||||||
this.update_in(cx, |this, window, cx| {
|
|
||||||
this.uploading(false, cx);
|
|
||||||
this.avatar_input.update(cx, |this, cx| {
|
|
||||||
this.set_value(url.to_string(), window, cx);
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
}
|
|
||||||
Ok(Err(e)) => {
|
|
||||||
Self::notify_error(cx, this, e.to_string());
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
Self::notify_error(cx, this, e.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.detach();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn notify_error(cx: &mut AsyncWindowContext, entity: WeakEntity<NewAccount>, e: String) {
|
|
||||||
cx.update(|window, cx| {
|
|
||||||
entity
|
|
||||||
.update(cx, |this, cx| {
|
|
||||||
window.push_notification(e, cx);
|
|
||||||
this.uploading(false, cx);
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn submitting(&mut self, status: bool, cx: &mut Context<Self>) {
|
|
||||||
self.submitting = status;
|
|
||||||
cx.notify();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn uploading(&mut self, status: bool, cx: &mut Context<Self>) {
|
|
||||||
self.uploading = status;
|
|
||||||
cx.notify();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Panel for NewAccount {
|
|
||||||
fn panel_id(&self) -> SharedString {
|
|
||||||
self.name.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn title(&self, _cx: &App) -> AnyElement {
|
|
||||||
self.name.clone().into_any_element()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn popup_menu(&self, menu: PopupMenu, _cx: &App) -> PopupMenu {
|
|
||||||
menu.track_focus(&self.focus_handle)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn toolbar_buttons(&self, _window: &Window, _cx: &App) -> Vec<Button> {
|
|
||||||
vec![]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl EventEmitter<PanelEvent> for NewAccount {}
|
|
||||||
|
|
||||||
impl Focusable for NewAccount {
|
|
||||||
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
|
|
||||||
self.focus_handle.clone()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Render for NewAccount {
|
|
||||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
|
||||||
v_flex()
|
|
||||||
.size_full()
|
|
||||||
.relative()
|
|
||||||
.items_center()
|
|
||||||
.justify_center()
|
|
||||||
.gap_10()
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.text_lg()
|
|
||||||
.text_center()
|
|
||||||
.font_semibold()
|
|
||||||
.line_height(relative(1.3))
|
|
||||||
.child(shared_t!("new_account.title")),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
v_flex()
|
|
||||||
.w_96()
|
|
||||||
.gap_4()
|
|
||||||
.child(
|
|
||||||
v_flex()
|
|
||||||
.gap_1()
|
|
||||||
.text_sm()
|
|
||||||
.child(shared_t!("new_account.name"))
|
|
||||||
.child(TextInput::new(&self.name_input).small()),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
v_flex()
|
|
||||||
.gap_1()
|
|
||||||
.child(div().text_sm().child(shared_t!("new_account.avatar")))
|
|
||||||
.child(
|
|
||||||
v_flex()
|
|
||||||
.p_1()
|
|
||||||
.h_32()
|
|
||||||
.w_full()
|
|
||||||
.items_center()
|
|
||||||
.justify_center()
|
|
||||||
.gap_2()
|
|
||||||
.rounded(cx.theme().radius)
|
|
||||||
.border_1()
|
|
||||||
.border_dashed()
|
|
||||||
.border_color(cx.theme().border)
|
|
||||||
.child(
|
|
||||||
Avatar::new(self.avatar_input.read(cx).value().to_string())
|
|
||||||
.size(rems(2.25)),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
Button::new("upload")
|
|
||||||
.icon(IconName::Plus)
|
|
||||||
.label(t!("common.upload"))
|
|
||||||
.ghost()
|
|
||||||
.small()
|
|
||||||
.rounded()
|
|
||||||
.disabled(self.submitting || self.uploading)
|
|
||||||
.loading(self.uploading)
|
|
||||||
.on_click(cx.listener(move |this, _, window, cx| {
|
|
||||||
this.upload(window, cx);
|
|
||||||
})),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.child(divider(cx))
|
|
||||||
.child(
|
|
||||||
Button::new("submit")
|
|
||||||
.label(t!("common.continue"))
|
|
||||||
.primary()
|
|
||||||
.loading(self.submitting)
|
|
||||||
.disabled(self.submitting || self.uploading)
|
|
||||||
.on_click(cx.listener(move |this, _, window, cx| {
|
|
||||||
this.create(window, cx);
|
|
||||||
})),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,27 +1,25 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use client_keys::ClientKeys;
|
use common::{TextUtils, CLIENT_NAME, NOSTR_CONNECT_RELAY, NOSTR_CONNECT_TIMEOUT};
|
||||||
use common::display::TextUtils;
|
|
||||||
use global::constants::{ACCOUNT_IDENTIFIER, APP_NAME, NOSTR_CONNECT_RELAY, NOSTR_CONNECT_TIMEOUT};
|
|
||||||
use global::nostr_client;
|
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
div, img, px, relative, svg, AnyElement, App, AppContext, ClipboardItem, Context, Entity,
|
div, img, px, relative, svg, AnyElement, App, AppContext, Context, Entity, EventEmitter,
|
||||||
EventEmitter, FocusHandle, Focusable, Image, InteractiveElement, IntoElement, ParentElement,
|
FocusHandle, Focusable, Image, InteractiveElement, IntoElement, ParentElement, Render,
|
||||||
Render, SharedString, StatefulInteractiveElement, Styled, Subscription, Task, Window,
|
SharedString, StatefulInteractiveElement, Styled, Task, Window,
|
||||||
};
|
};
|
||||||
use i18n::{shared_t, t};
|
use i18n::{shared_t, t};
|
||||||
|
use key_store::{KeyItem, KeyStore};
|
||||||
use nostr_connect::prelude::*;
|
use nostr_connect::prelude::*;
|
||||||
use smallvec::{smallvec, SmallVec};
|
use smallvec::{smallvec, SmallVec};
|
||||||
|
use state::NostrRegistry;
|
||||||
use theme::ActiveTheme;
|
use theme::ActiveTheme;
|
||||||
use ui::button::{Button, ButtonVariants};
|
use ui::button::{Button, ButtonVariants};
|
||||||
use ui::dock_area::panel::{Panel, PanelEvent};
|
use ui::dock_area::panel::{Panel, PanelEvent};
|
||||||
use ui::notification::Notification;
|
use ui::notification::Notification;
|
||||||
use ui::popup_menu::PopupMenu;
|
|
||||||
use ui::{divider, h_flex, v_flex, ContextModal, Icon, IconName, Sizable, StyledExt};
|
use ui::{divider, h_flex, v_flex, ContextModal, Icon, IconName, Sizable, StyledExt};
|
||||||
|
|
||||||
use crate::chatspace::{self, ChatSpace};
|
use crate::chatspace::{self};
|
||||||
|
|
||||||
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)
|
||||||
@@ -59,14 +57,14 @@ impl NostrConnectApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub struct Onboarding {
|
pub struct Onboarding {
|
||||||
nostr_connect_uri: Entity<NostrConnectURI>,
|
app_keys: Keys,
|
||||||
nostr_connect: Entity<Option<NostrConnect>>,
|
qr_code: Option<Arc<Image>>,
|
||||||
qr_code: Entity<Option<Arc<Image>>>,
|
|
||||||
connecting: bool,
|
/// Panel
|
||||||
// Panel
|
|
||||||
name: SharedString,
|
name: SharedString,
|
||||||
focus_handle: FocusHandle,
|
focus_handle: FocusHandle,
|
||||||
_subscriptions: SmallVec<[Subscription; 2]>,
|
|
||||||
|
/// Background tasks
|
||||||
_tasks: SmallVec<[Task<()>; 1]>,
|
_tasks: SmallVec<[Task<()>; 1]>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,149 +74,103 @@ impl Onboarding {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn view(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
fn view(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||||
let nostr_connect = cx.new(|_| None);
|
let app_keys = Keys::generate();
|
||||||
let qr_code = cx.new(|_| None);
|
let timeout = Duration::from_secs(NOSTR_CONNECT_TIMEOUT);
|
||||||
|
|
||||||
|
let relay = RelayUrl::parse(NOSTR_CONNECT_RELAY).unwrap();
|
||||||
|
let uri = NostrConnectUri::client(app_keys.public_key(), vec![relay], CLIENT_NAME);
|
||||||
|
let qr_code = uri.to_string().to_qr();
|
||||||
|
|
||||||
// NIP46: https://github.com/nostr-protocol/nips/blob/master/46.md
|
// NIP46: https://github.com/nostr-protocol/nips/blob/master/46.md
|
||||||
//
|
//
|
||||||
// Direct connection initiated by the client
|
// Direct connection initiated by the client
|
||||||
let nostr_connect_uri = cx.new(|cx| {
|
let signer = NostrConnect::new(uri, app_keys.clone(), timeout, None).unwrap();
|
||||||
let relay = RelayUrl::parse(NOSTR_CONNECT_RELAY).unwrap();
|
|
||||||
let app_keys = ClientKeys::read_global(cx).keys();
|
|
||||||
NostrConnectURI::client(app_keys.public_key(), vec![relay], APP_NAME)
|
|
||||||
});
|
|
||||||
|
|
||||||
let mut subscriptions = smallvec![];
|
let mut tasks = smallvec![];
|
||||||
|
|
||||||
// Clean up when the current view is released
|
tasks.push(
|
||||||
subscriptions.push(cx.on_release_in(window, |this, window, cx| {
|
// Wait for nostr connect
|
||||||
this.shutdown_nostr_connect(window, cx);
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Set Nostr Connect after the view is initialized
|
|
||||||
cx.defer_in(window, |this, window, cx| {
|
|
||||||
this.set_connect(window, cx);
|
|
||||||
});
|
|
||||||
|
|
||||||
Self {
|
|
||||||
nostr_connect,
|
|
||||||
nostr_connect_uri,
|
|
||||||
qr_code,
|
|
||||||
connecting: false,
|
|
||||||
name: "Onboarding".into(),
|
|
||||||
focus_handle: cx.focus_handle(),
|
|
||||||
_subscriptions: subscriptions,
|
|
||||||
_tasks: smallvec![],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_connecting(&mut self, cx: &mut Context<Self>) {
|
|
||||||
self.connecting = true;
|
|
||||||
cx.notify();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_connect(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
||||||
let uri = self.nostr_connect_uri.read(cx).clone();
|
|
||||||
let app_keys = ClientKeys::read_global(cx).keys();
|
|
||||||
let timeout = Duration::from_secs(NOSTR_CONNECT_TIMEOUT);
|
|
||||||
|
|
||||||
self.qr_code.update(cx, |this, cx| {
|
|
||||||
*this = uri.to_string().to_qr();
|
|
||||||
cx.notify();
|
|
||||||
});
|
|
||||||
|
|
||||||
self.nostr_connect.update(cx, |this, cx| {
|
|
||||||
*this = NostrConnect::new(uri, app_keys, timeout, None).ok();
|
|
||||||
cx.notify();
|
|
||||||
});
|
|
||||||
|
|
||||||
self._tasks.push(
|
|
||||||
// Wait for Nostr Connect approval
|
|
||||||
cx.spawn_in(window, async move |this, cx| {
|
cx.spawn_in(window, async move |this, cx| {
|
||||||
let connect = this.read_with(cx, |this, cx| this.nostr_connect.read(cx).clone());
|
let result = signer.bunker_uri().await;
|
||||||
|
|
||||||
if let Ok(Some(signer)) = connect {
|
this.update_in(cx, |this, window, cx| {
|
||||||
match signer.bunker_uri().await {
|
match result {
|
||||||
Ok(uri) => {
|
Ok(uri) => {
|
||||||
this.update(cx, |this, cx| {
|
this.save_connection(&uri, window, cx);
|
||||||
this.set_connecting(cx);
|
this.connect(signer, cx);
|
||||||
this.write_uri_to_disk(signer, uri, cx);
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
this.update_in(cx, |_, window, cx| {
|
window.push_notification(Notification::error(e.to_string()), cx);
|
||||||
window.push_notification(
|
}
|
||||||
Notification::error(e.to_string()).title("Nostr Connect"),
|
};
|
||||||
cx,
|
})
|
||||||
|
.ok();
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
qr_code,
|
||||||
|
app_keys,
|
||||||
|
name: "Onboarding".into(),
|
||||||
|
focus_handle: cx.focus_handle(),
|
||||||
|
_tasks: tasks,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_connection(
|
||||||
|
&mut self,
|
||||||
|
uri: &NostrConnectUri,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) {
|
||||||
|
let keystore = KeyStore::global(cx).read(cx).backend();
|
||||||
|
let username = self.app_keys.public_key().to_hex();
|
||||||
|
let secret = self.app_keys.secret_key().to_secret_bytes();
|
||||||
|
let mut clean_uri = uri.to_string();
|
||||||
|
|
||||||
|
// Clear the secret parameter in the URI if it exists
|
||||||
|
if let Some(s) = uri.secret() {
|
||||||
|
clean_uri = clean_uri.replace(s, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
cx.spawn_in(window, async move |this, cx| {
|
||||||
|
let user_url = KeyItem::User.to_string();
|
||||||
|
let bunker_url = KeyItem::Bunker.to_string();
|
||||||
|
let user_password = clean_uri.into_bytes();
|
||||||
|
|
||||||
|
// Write bunker uri to keyring for further connection
|
||||||
|
if let Err(e) = keystore
|
||||||
|
.write_credentials(&user_url, "bunker", &user_password, cx)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
this.update_in(cx, |_, window, cx| {
|
||||||
|
window.push_notification(e.to_string(), cx);
|
||||||
})
|
})
|
||||||
.ok();
|
.ok();
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
// Write the app keys for further connection
|
||||||
|
if let Err(e) = keystore
|
||||||
|
.write_credentials(&bunker_url, &username, &secret, cx)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
this.update_in(cx, |_, window, cx| {
|
||||||
|
window.push_notification(e.to_string(), cx);
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
}
|
}
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_proxy(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
||||||
ChatSpace::proxy_signer(window, cx);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_uri_to_disk(
|
|
||||||
&mut self,
|
|
||||||
signer: NostrConnect,
|
|
||||||
uri: NostrConnectURI,
|
|
||||||
cx: &mut Context<Self>,
|
|
||||||
) {
|
|
||||||
let mut uri_without_secret = uri.to_string();
|
|
||||||
|
|
||||||
// Clear the secret parameter in the URI if it exists
|
|
||||||
if let Some(secret) = uri.secret() {
|
|
||||||
uri_without_secret = uri_without_secret.replace(secret, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
let task: Task<Result<(), anyhow::Error>> = cx.background_spawn(async move {
|
|
||||||
let client = nostr_client();
|
|
||||||
|
|
||||||
// Update the client's signer
|
|
||||||
client.set_signer(signer).await;
|
|
||||||
|
|
||||||
let signer = client.signer().await?;
|
|
||||||
let public_key = signer.get_public_key().await?;
|
|
||||||
|
|
||||||
let event = EventBuilder::new(Kind::ApplicationSpecificData, uri_without_secret)
|
|
||||||
.tags(vec![Tag::identifier(ACCOUNT_IDENTIFIER)])
|
|
||||||
.build(public_key)
|
|
||||||
.sign(&Keys::generate())
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
// Save the event to the database
|
|
||||||
client.database().save_event(&event).await?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
});
|
|
||||||
|
|
||||||
task.detach();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn copy_uri(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
||||||
cx.write_to_clipboard(ClipboardItem::new_string(
|
|
||||||
self.nostr_connect_uri.read(cx).to_string(),
|
|
||||||
));
|
|
||||||
window.push_notification(t!("common.copied"), cx);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn shutdown_nostr_connect(&mut self, _window: &mut Window, cx: &mut App) {
|
|
||||||
if !self.connecting {
|
|
||||||
if let Some(signer) = self.nostr_connect.read(cx).clone() {
|
|
||||||
cx.background_spawn(async move {
|
|
||||||
log::info!("Shutting down Nostr Connect");
|
|
||||||
signer.shutdown().await;
|
|
||||||
})
|
})
|
||||||
.detach();
|
.detach();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
fn connect(&mut self, signer: NostrConnect, cx: &mut Context<Self>) {
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let client = nostr.read(cx).client();
|
||||||
|
|
||||||
|
cx.background_spawn(async move {
|
||||||
|
client.set_signer(signer).await;
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_apps(&self, cx: &Context<Self>) -> impl IntoIterator<Item = impl IntoElement> {
|
fn render_apps(&self, cx: &Context<Self>) -> impl IntoIterator<Item = impl IntoElement> {
|
||||||
@@ -261,10 +213,6 @@ impl Panel for Onboarding {
|
|||||||
fn title(&self, _cx: &App) -> AnyElement {
|
fn title(&self, _cx: &App) -> AnyElement {
|
||||||
self.name.clone().into_any_element()
|
self.name.clone().into_any_element()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn popup_menu(&self, menu: PopupMenu, _cx: &App) -> PopupMenu {
|
|
||||||
menu.track_focus(&self.focus_handle)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EventEmitter<PanelEvent> for Onboarding {}
|
impl EventEmitter<PanelEvent> for Onboarding {}
|
||||||
@@ -276,7 +224,7 @@ impl Focusable for Onboarding {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Render for Onboarding {
|
impl Render for Onboarding {
|
||||||
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
|
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
h_flex()
|
h_flex()
|
||||||
.size_full()
|
.size_full()
|
||||||
.child(
|
.child(
|
||||||
@@ -346,30 +294,11 @@ impl Render for Onboarding {
|
|||||||
.child(
|
.child(
|
||||||
Button::new("key")
|
Button::new("key")
|
||||||
.label(t!("onboarding.key_login"))
|
.label(t!("onboarding.key_login"))
|
||||||
|
.large()
|
||||||
.ghost_alt()
|
.ghost_alt()
|
||||||
.on_click(cx.listener(move |_, _, window, cx| {
|
.on_click(cx.listener(move |_, _, window, cx| {
|
||||||
chatspace::login(window, cx);
|
chatspace::login(window, cx);
|
||||||
})),
|
})),
|
||||||
)
|
|
||||||
.child(
|
|
||||||
v_flex()
|
|
||||||
.gap_1()
|
|
||||||
.child(
|
|
||||||
Button::new("ext")
|
|
||||||
.label(t!("onboarding.ext_login"))
|
|
||||||
.ghost_alt()
|
|
||||||
.on_click(cx.listener(move |this, _, window, cx| {
|
|
||||||
this.set_proxy(window, cx);
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.italic()
|
|
||||||
.text_xs()
|
|
||||||
.text_center()
|
|
||||||
.text_color(cx.theme().text_muted)
|
|
||||||
.child(shared_t!("onboarding.ext_login_note")),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -391,11 +320,8 @@ impl Render for Onboarding {
|
|||||||
.gap_5()
|
.gap_5()
|
||||||
.items_center()
|
.items_center()
|
||||||
.justify_center()
|
.justify_center()
|
||||||
.when_some(self.qr_code.read(cx).as_ref(), |this, qr| {
|
.when_some(self.qr_code.as_ref(), |this, qr| {
|
||||||
this.child(
|
this.child(
|
||||||
div()
|
|
||||||
.id("")
|
|
||||||
.child(
|
|
||||||
img(qr.clone())
|
img(qr.clone())
|
||||||
.size(px(256.))
|
.size(px(256.))
|
||||||
.rounded_xl()
|
.rounded_xl()
|
||||||
@@ -403,12 +329,6 @@ impl Render for Onboarding {
|
|||||||
.border_1()
|
.border_1()
|
||||||
.border_color(cx.theme().element_active),
|
.border_color(cx.theme().element_active),
|
||||||
)
|
)
|
||||||
.on_click(cx.listener(
|
|
||||||
move |this, _e, window, cx| {
|
|
||||||
this.copy_uri(window, cx)
|
|
||||||
},
|
|
||||||
)),
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
.child(
|
.child(
|
||||||
v_flex()
|
v_flex()
|
||||||
|
|||||||
@@ -1,23 +1,15 @@
|
|||||||
use common::display::RenderedProfile;
|
|
||||||
use gpui::http_client::Url;
|
use gpui::http_client::Url;
|
||||||
use gpui::prelude::FluentBuilder;
|
|
||||||
use gpui::{
|
use gpui::{
|
||||||
div, px, relative, rems, App, AppContext, Context, Entity, InteractiveElement, IntoElement,
|
div, px, App, AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString,
|
||||||
ParentElement, Render, SharedString, StatefulInteractiveElement, Styled, Window,
|
Styled, Window,
|
||||||
};
|
};
|
||||||
use i18n::{shared_t, t};
|
use i18n::{shared_t, t};
|
||||||
use nostr_sdk::prelude::*;
|
|
||||||
use registry::Registry;
|
|
||||||
use settings::AppSettings;
|
use settings::AppSettings;
|
||||||
use theme::ActiveTheme;
|
use theme::ActiveTheme;
|
||||||
use ui::avatar::Avatar;
|
|
||||||
use ui::button::{Button, ButtonVariants};
|
use ui::button::{Button, ButtonVariants};
|
||||||
use ui::input::{InputState, TextInput};
|
use ui::input::{InputState, TextInput};
|
||||||
use ui::modal::ModalButtonProps;
|
|
||||||
use ui::switch::Switch;
|
use ui::switch::Switch;
|
||||||
use ui::{h_flex, v_flex, ContextModal, IconName, Sizable, Size, StyledExt};
|
use ui::{h_flex, v_flex, IconName, Sizable, Size, StyledExt};
|
||||||
|
|
||||||
use crate::views::{edit_profile, setup_relay};
|
|
||||||
|
|
||||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Preferences> {
|
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Preferences> {
|
||||||
cx.new(|cx| Preferences::new(window, cx))
|
cx.new(|cx| Preferences::new(window, cx))
|
||||||
@@ -38,76 +30,6 @@ impl Preferences {
|
|||||||
|
|
||||||
Self { media_input }
|
Self { media_input }
|
||||||
}
|
}
|
||||||
|
|
||||||
fn open_edit_profile(&self, window: &mut Window, cx: &mut Context<Self>) {
|
|
||||||
let view = edit_profile::init(window, cx);
|
|
||||||
let weak_view = view.downgrade();
|
|
||||||
|
|
||||||
window.open_modal(cx, move |modal, _window, _cx| {
|
|
||||||
let weak_view = weak_view.clone();
|
|
||||||
|
|
||||||
modal
|
|
||||||
.confirm()
|
|
||||||
.title(shared_t!("profile.title"))
|
|
||||||
.child(view.clone())
|
|
||||||
.button_props(ModalButtonProps::default().ok_text(t!("common.update")))
|
|
||||||
.on_ok(move |_, window, cx| {
|
|
||||||
weak_view
|
|
||||||
.update(cx, |this, cx| {
|
|
||||||
let set_metadata = this.set_metadata(cx);
|
|
||||||
let registry = Registry::global(cx);
|
|
||||||
|
|
||||||
cx.spawn_in(window, async move |_, cx| {
|
|
||||||
match set_metadata.await {
|
|
||||||
Ok(profile) => {
|
|
||||||
if let Some(profile) = profile {
|
|
||||||
cx.update(|_, cx| {
|
|
||||||
registry.update(cx, |this, cx| {
|
|
||||||
this.insert_or_update_person(profile, cx);
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
cx.update(|window, cx| {
|
|
||||||
window.push_notification(e.to_string(), cx);
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.detach();
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
// true to close the modal
|
|
||||||
true
|
|
||||||
})
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn open_relays(&self, window: &mut Window, cx: &mut Context<Self>) {
|
|
||||||
let view = setup_relay::init(Kind::InboxRelays, window, cx);
|
|
||||||
let weak_view = view.downgrade();
|
|
||||||
|
|
||||||
window.open_modal(cx, move |this, _window, _cx| {
|
|
||||||
let weak_view = weak_view.clone();
|
|
||||||
|
|
||||||
this.confirm()
|
|
||||||
.title(shared_t!("relays.modal"))
|
|
||||||
.child(view.clone())
|
|
||||||
.button_props(ModalButtonProps::default().ok_text(t!("common.update")))
|
|
||||||
.on_ok(move |_, window, cx| {
|
|
||||||
weak_view
|
|
||||||
.update(cx, |this, cx| {
|
|
||||||
this.set_relays(window, cx);
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
// true to close the modal
|
|
||||||
false
|
|
||||||
})
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Render for Preferences {
|
impl Render for Preferences {
|
||||||
@@ -119,70 +41,9 @@ impl Render for Preferences {
|
|||||||
let proxy = AppSettings::get_proxy_user_avatars(cx);
|
let proxy = AppSettings::get_proxy_user_avatars(cx);
|
||||||
let hide = AppSettings::get_hide_user_avatars(cx);
|
let hide = AppSettings::get_hide_user_avatars(cx);
|
||||||
|
|
||||||
let registry = Registry::read_global(cx);
|
|
||||||
let input_state = self.media_input.downgrade();
|
let input_state = self.media_input.downgrade();
|
||||||
|
|
||||||
v_flex()
|
v_flex()
|
||||||
.child(
|
|
||||||
v_flex()
|
|
||||||
.pb_2()
|
|
||||||
.gap_2()
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.text_sm()
|
|
||||||
.text_color(cx.theme().text_placeholder)
|
|
||||||
.font_semibold()
|
|
||||||
.child(shared_t!("preferences.account_header")),
|
|
||||||
)
|
|
||||||
.when_some(registry.signer_pubkey(), |this, public_key| {
|
|
||||||
let profile = registry.get_person(&public_key, cx);
|
|
||||||
|
|
||||||
this.child(
|
|
||||||
h_flex()
|
|
||||||
.w_full()
|
|
||||||
.justify_between()
|
|
||||||
.child(
|
|
||||||
h_flex()
|
|
||||||
.id("user")
|
|
||||||
.gap_2()
|
|
||||||
.child(Avatar::new(profile.avatar(proxy)).size(rems(2.4)))
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.flex_1()
|
|
||||||
.text_sm()
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.font_semibold()
|
|
||||||
.line_height(relative(1.3))
|
|
||||||
.child(profile.display_name()),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.text_xs()
|
|
||||||
.text_color(cx.theme().text_muted)
|
|
||||||
.line_height(relative(1.3))
|
|
||||||
.child(shared_t!(
|
|
||||||
"preferences.account_btn"
|
|
||||||
)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.on_click(cx.listener(move |this, _e, window, cx| {
|
|
||||||
this.open_edit_profile(window, cx);
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
Button::new("relays")
|
|
||||||
.label("Messaging Relays")
|
|
||||||
.xsmall()
|
|
||||||
.ghost_alt()
|
|
||||||
.rounded()
|
|
||||||
.on_click(cx.listener(move |this, _e, window, cx| {
|
|
||||||
this.open_relays(window, cx);
|
|
||||||
})),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.child(
|
.child(
|
||||||
v_flex()
|
v_flex()
|
||||||
.py_2()
|
.py_2()
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use common::display::{shorten_pubkey, RenderedProfile, RenderedTimestamp};
|
use common::{nip05_verify, shorten_pubkey, RenderedProfile, RenderedTimestamp, BOOTSTRAP_RELAYS};
|
||||||
use common::nip05::nip05_verify;
|
|
||||||
use global::constants::BOOTSTRAP_RELAYS;
|
|
||||||
use global::nostr_client;
|
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
div, px, relative, rems, uniform_list, App, AppContext, Context, Div, Entity,
|
div, px, relative, rems, uniform_list, App, AppContext, Context, Div, Entity,
|
||||||
@@ -12,9 +9,10 @@ use gpui::{
|
|||||||
use gpui_tokio::Tokio;
|
use gpui_tokio::Tokio;
|
||||||
use i18n::{shared_t, t};
|
use i18n::{shared_t, t};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use registry::Registry;
|
use person::PersonRegistry;
|
||||||
use settings::AppSettings;
|
use settings::AppSettings;
|
||||||
use smallvec::{smallvec, SmallVec};
|
use smallvec::{smallvec, SmallVec};
|
||||||
|
use state::NostrRegistry;
|
||||||
use theme::ActiveTheme;
|
use theme::ActiveTheme;
|
||||||
use ui::avatar::Avatar;
|
use ui::avatar::Avatar;
|
||||||
use ui::button::{Button, ButtonVariants};
|
use ui::button::{Button, ButtonVariants};
|
||||||
@@ -36,14 +34,17 @@ pub struct Screening {
|
|||||||
|
|
||||||
impl Screening {
|
impl Screening {
|
||||||
pub fn new(public_key: PublicKey, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
pub fn new(public_key: PublicKey, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||||
let registry = Registry::read_global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let profile = registry.get_person(&public_key, cx);
|
let client = nostr.read(cx).client();
|
||||||
|
|
||||||
|
let persons = PersonRegistry::global(cx);
|
||||||
|
let profile = persons.read(cx).get_person(&public_key, cx);
|
||||||
|
|
||||||
let mut tasks = smallvec![];
|
let mut tasks = smallvec![];
|
||||||
|
|
||||||
let contact_check: Task<Result<(bool, Vec<Profile>), Error>> =
|
let contact_check: Task<Result<(bool, Vec<Profile>), Error>> = cx.background_spawn({
|
||||||
cx.background_spawn(async move {
|
let client = nostr.read(cx).client();
|
||||||
let client = nostr_client();
|
async move {
|
||||||
let signer = client.signer().await?;
|
let signer = client.signer().await?;
|
||||||
let signer_pubkey = signer.get_public_key().await?;
|
let signer_pubkey = signer.get_public_key().await?;
|
||||||
|
|
||||||
@@ -65,10 +66,10 @@ impl Screening {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Ok((followed, mutual_contacts))
|
Ok((followed, mutual_contacts))
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let activity_check = cx.background_spawn(async move {
|
let activity_check = cx.background_spawn(async move {
|
||||||
let client = nostr_client();
|
|
||||||
let filter = Filter::new().author(public_key).limit(1);
|
let filter = Filter::new().author(public_key).limit(1);
|
||||||
let mut activity: Option<Timestamp> = None;
|
let mut activity: Option<Timestamp> = None;
|
||||||
|
|
||||||
@@ -154,15 +155,17 @@ impl Screening {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn report(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
fn report(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let client = nostr.read(cx).client();
|
||||||
let public_key = self.profile.public_key();
|
let public_key = self.profile.public_key();
|
||||||
|
|
||||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||||
let client = nostr_client();
|
let signer = client.signer().await?;
|
||||||
let builder = EventBuilder::report(
|
let tag = Tag::public_key_report(public_key, Report::Impersonation);
|
||||||
vec![Tag::public_key_report(public_key, Report::Impersonation)],
|
let event = EventBuilder::report(vec![tag], "").sign(&signer).await?;
|
||||||
"scam/impersonation",
|
|
||||||
);
|
// Send the report to the public relays
|
||||||
let _ = client.send_event_builder(builder).await?;
|
client.send_event_to(BOOTSTRAP_RELAYS, &event).await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,114 +1,61 @@
|
|||||||
|
use std::collections::HashSet;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::{anyhow, Error};
|
use anyhow::{anyhow, Error};
|
||||||
use global::constants::NIP17_RELAYS;
|
|
||||||
use global::{app_state, nostr_client};
|
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
div, px, uniform_list, App, AppContext, Context, Entity, InteractiveElement, IntoElement,
|
div, px, uniform_list, App, AppContext, Context, Entity, InteractiveElement, IntoElement,
|
||||||
ParentElement, Render, SharedString, StatefulInteractiveElement, Styled, Subscription, Task,
|
ParentElement, Render, SharedString, Styled, Subscription, Task, TextAlign, UniformList,
|
||||||
TextAlign, UniformList, Window,
|
Window,
|
||||||
};
|
};
|
||||||
use i18n::{shared_t, t};
|
use i18n::{shared_t, t};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use smallvec::{smallvec, SmallVec};
|
use smallvec::{smallvec, SmallVec};
|
||||||
|
use state::NostrRegistry;
|
||||||
use theme::ActiveTheme;
|
use theme::ActiveTheme;
|
||||||
use ui::button::{Button, ButtonVariants};
|
use ui::button::{Button, ButtonVariants};
|
||||||
use ui::input::{InputEvent, InputState, TextInput};
|
use ui::input::{InputEvent, InputState, TextInput};
|
||||||
use ui::modal::ModalButtonProps;
|
use ui::{h_flex, v_flex, ContextModal, IconName, Sizable};
|
||||||
use ui::{h_flex, v_flex, ContextModal, IconName, Sizable, StyledExt};
|
|
||||||
|
|
||||||
pub fn init(kind: Kind, window: &mut Window, cx: &mut App) -> Entity<SetupRelay> {
|
pub fn init(window: &mut Window, cx: &mut App) -> Entity<SetupRelay> {
|
||||||
cx.new(|cx| SetupRelay::new(kind, window, cx))
|
cx.new(|cx| SetupRelay::new(window, cx))
|
||||||
}
|
|
||||||
|
|
||||||
pub fn setup_nip17_relay<T>(label: T) -> impl IntoElement
|
|
||||||
where
|
|
||||||
T: Into<SharedString>,
|
|
||||||
{
|
|
||||||
div().child(
|
|
||||||
Button::new("setup-relays")
|
|
||||||
.icon(IconName::Info)
|
|
||||||
.label(label)
|
|
||||||
.warning()
|
|
||||||
.xsmall()
|
|
||||||
.rounded()
|
|
||||||
.on_click(move |_, window, cx| {
|
|
||||||
let view = cx.new(|cx| SetupRelay::new(Kind::InboxRelays, window, cx));
|
|
||||||
let weak_view = view.downgrade();
|
|
||||||
|
|
||||||
window.open_modal(cx, move |modal, _window, _cx| {
|
|
||||||
let weak_view = weak_view.clone();
|
|
||||||
|
|
||||||
modal
|
|
||||||
.confirm()
|
|
||||||
.title(shared_t!("relays.modal"))
|
|
||||||
.child(view.clone())
|
|
||||||
.button_props(ModalButtonProps::default().ok_text(t!("common.update")))
|
|
||||||
.on_ok(move |_, window, cx| {
|
|
||||||
weak_view
|
|
||||||
.update(cx, |this, cx| {
|
|
||||||
this.set_relays(window, cx);
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
// true to close the modal
|
|
||||||
false
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
pub struct SetupRelay {
|
pub struct SetupRelay {
|
||||||
input: Entity<InputState>,
|
input: Entity<InputState>,
|
||||||
relays: Vec<RelayUrl>,
|
|
||||||
error: Option<SharedString>,
|
error: Option<SharedString>,
|
||||||
|
|
||||||
|
// All relays
|
||||||
|
relays: HashSet<RelayUrl>,
|
||||||
|
|
||||||
|
// Event subscriptions
|
||||||
_subscriptions: SmallVec<[Subscription; 1]>,
|
_subscriptions: SmallVec<[Subscription; 1]>,
|
||||||
|
|
||||||
|
// Background tasks
|
||||||
_tasks: SmallVec<[Task<()>; 1]>,
|
_tasks: SmallVec<[Task<()>; 1]>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SetupRelay {
|
impl SetupRelay {
|
||||||
pub fn new(kind: Kind, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let client = nostr.read(cx).client();
|
||||||
|
|
||||||
let input = cx.new(|cx| InputState::new(window, cx).placeholder("wss://example.com"));
|
let input = cx.new(|cx| InputState::new(window, cx).placeholder("wss://example.com"));
|
||||||
|
|
||||||
let mut subscriptions = smallvec![];
|
let mut subscriptions = smallvec![];
|
||||||
let mut tasks = smallvec![];
|
let mut tasks = smallvec![];
|
||||||
|
|
||||||
let load_relay = cx.background_spawn(async move {
|
|
||||||
let client = nostr_client();
|
|
||||||
let signer = client.signer().await?;
|
|
||||||
let public_key = signer.get_public_key().await?;
|
|
||||||
|
|
||||||
let filter = Filter::new().kind(kind).author(public_key).limit(1);
|
|
||||||
|
|
||||||
if let Some(event) = client.database().query(filter).await?.first() {
|
|
||||||
let relays: Vec<RelayUrl> = event
|
|
||||||
.tags
|
|
||||||
.iter()
|
|
||||||
.filter_map(|tag| tag.as_standardized())
|
|
||||||
.filter_map(|tag| {
|
|
||||||
if let TagStandard::RelayMetadata { relay_url, .. } = tag {
|
|
||||||
Some(relay_url.to_owned())
|
|
||||||
} else if let TagStandard::Relay(url) = tag {
|
|
||||||
Some(url.to_owned())
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
Ok(relays)
|
|
||||||
} else {
|
|
||||||
Err(anyhow!("Not found."))
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tasks.push(
|
tasks.push(
|
||||||
// Load user's relays in the local database
|
// Load user's relays in the local database
|
||||||
cx.spawn_in(window, async move |this, cx| {
|
cx.spawn_in(window, async move |this, cx| {
|
||||||
if let Ok(relays) = load_relay.await {
|
let result = cx
|
||||||
|
.background_spawn(async move { Self::load(&client).await })
|
||||||
|
.await;
|
||||||
|
|
||||||
|
if let Ok(relays) = result {
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
this.relays = relays;
|
this.relays.extend(relays);
|
||||||
cx.notify();
|
cx.notify();
|
||||||
})
|
})
|
||||||
.ok();
|
.ok();
|
||||||
@@ -131,35 +78,52 @@ impl SetupRelay {
|
|||||||
|
|
||||||
Self {
|
Self {
|
||||||
input,
|
input,
|
||||||
relays: vec![],
|
relays: HashSet::new(),
|
||||||
error: None,
|
error: None,
|
||||||
_subscriptions: subscriptions,
|
_subscriptions: subscriptions,
|
||||||
_tasks: tasks,
|
_tasks: tasks,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn load(client: &Client) -> Result<Vec<RelayUrl>, Error> {
|
||||||
|
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 urls = nip17::extract_owned_relay_list(event).collect();
|
||||||
|
Ok(urls)
|
||||||
|
} else {
|
||||||
|
Err(anyhow!("Not found."))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn add(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
fn add(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
let value = self.input.read(cx).value().to_string();
|
let value = self.input.read(cx).value().to_string();
|
||||||
|
|
||||||
if !value.starts_with("ws") {
|
if !value.starts_with("ws") {
|
||||||
|
self.set_error("Relay URl is invalid", window, cx);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Ok(url) = RelayUrl::parse(&value) {
|
if let Ok(url) = RelayUrl::parse(&value) {
|
||||||
if !self.relays.contains(&url) {
|
if !self.relays.insert(url) {
|
||||||
self.relays.push(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
self.input.update(cx, |this, cx| {
|
self.input.update(cx, |this, cx| {
|
||||||
this.set_value("", window, cx);
|
this.set_value("", window, cx);
|
||||||
});
|
});
|
||||||
|
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
self.set_error("Relay URl is invalid", window, cx);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn remove(&mut self, ix: usize, _window: &mut Window, cx: &mut Context<Self>) {
|
fn remove(&mut self, url: &RelayUrl, cx: &mut Context<Self>) {
|
||||||
self.relays.remove(ix);
|
self.relays.remove(url);
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,15 +137,13 @@ impl SetupRelay {
|
|||||||
// Clear the error message after a delay
|
// Clear the error message after a delay
|
||||||
cx.spawn_in(window, async move |this, cx| {
|
cx.spawn_in(window, async move |this, cx| {
|
||||||
cx.background_executor().timer(Duration::from_secs(2)).await;
|
cx.background_executor().timer(Duration::from_secs(2)).await;
|
||||||
cx.update(|_, cx| {
|
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
this.error = None;
|
this.error = None;
|
||||||
cx.notify();
|
cx.notify();
|
||||||
})
|
})
|
||||||
.ok();
|
.ok();
|
||||||
})
|
})
|
||||||
.ok();
|
|
||||||
})
|
|
||||||
.detach();
|
.detach();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,12 +153,19 @@ impl SetupRelay {
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let client = nostr.read(cx).client();
|
||||||
|
let gossip = nostr.read(cx).gossip();
|
||||||
let relays = self.relays.clone();
|
let relays = self.relays.clone();
|
||||||
|
|
||||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||||
let client = nostr_client();
|
|
||||||
let signer = client.signer().await?;
|
let signer = client.signer().await?;
|
||||||
let public_key = signer.get_public_key().await?;
|
let public_key = signer.get_public_key().await?;
|
||||||
|
let gossip = gossip.read().await;
|
||||||
|
let write_relays = gossip.inbox_relays(&public_key);
|
||||||
|
|
||||||
|
// Ensure connections to the write relays
|
||||||
|
gossip.ensure_connections(&client, &write_relays).await;
|
||||||
|
|
||||||
let tags: Vec<Tag> = relays
|
let tags: Vec<Tag> = relays
|
||||||
.iter()
|
.iter()
|
||||||
@@ -205,31 +174,18 @@ impl SetupRelay {
|
|||||||
|
|
||||||
let event = EventBuilder::new(Kind::InboxRelays, "")
|
let event = EventBuilder::new(Kind::InboxRelays, "")
|
||||||
.tags(tags)
|
.tags(tags)
|
||||||
.build(public_key)
|
|
||||||
.sign(&signer)
|
.sign(&signer)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// Set messaging relays
|
// Set messaging relays
|
||||||
client.send_event(&event).await?;
|
client.send_event_to(write_relays, &event).await?;
|
||||||
|
|
||||||
// Connect to messaging relays
|
// Connect to messaging relays
|
||||||
for relay in relays.iter() {
|
for relay in relays.iter() {
|
||||||
_ = client.add_relay(relay).await;
|
client.add_relay(relay).await.ok();
|
||||||
_ = client.connect_relay(relay).await;
|
client.connect_relay(relay).await.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch gift wrap events
|
|
||||||
let sub_id = app_state().gift_wrap_sub_id.clone();
|
|
||||||
let filter = Filter::new().kind(Kind::GiftWrap).pubkey(public_key);
|
|
||||||
|
|
||||||
if client
|
|
||||||
.subscribe_with_id_to(relays.clone(), sub_id, filter, None)
|
|
||||||
.await
|
|
||||||
.is_ok()
|
|
||||||
{
|
|
||||||
log::info!("Subscribed to messages in: {relays:?}");
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -259,14 +215,19 @@ impl SetupRelay {
|
|||||||
uniform_list(
|
uniform_list(
|
||||||
"relays",
|
"relays",
|
||||||
total,
|
total,
|
||||||
cx.processor(move |_, range, _window, cx| {
|
cx.processor(move |_v, range, _window, cx| {
|
||||||
let mut items = Vec::new();
|
let mut items = Vec::new();
|
||||||
|
|
||||||
for ix in range {
|
for ix in range {
|
||||||
let item = relays.get(ix).map(|i: &RelayUrl| i.to_string()).unwrap();
|
if let Some(url) = relays.iter().nth(ix) {
|
||||||
|
|
||||||
items.push(
|
items.push(
|
||||||
div().group("").w_full().h_9().py_0p5().child(
|
div()
|
||||||
|
.id(SharedString::from(url.to_string()))
|
||||||
|
.group("")
|
||||||
|
.w_full()
|
||||||
|
.h_9()
|
||||||
|
.py_0p5()
|
||||||
|
.child(
|
||||||
div()
|
div()
|
||||||
.px_2()
|
.px_2()
|
||||||
.h_full()
|
.h_full()
|
||||||
@@ -277,7 +238,7 @@ impl SetupRelay {
|
|||||||
.rounded(cx.theme().radius)
|
.rounded(cx.theme().radius)
|
||||||
.bg(cx.theme().elevated_surface_background)
|
.bg(cx.theme().elevated_surface_background)
|
||||||
.text_xs()
|
.text_xs()
|
||||||
.child(item)
|
.child(SharedString::from(url.to_string()))
|
||||||
.child(
|
.child(
|
||||||
Button::new("remove_{ix}")
|
Button::new("remove_{ix}")
|
||||||
.icon(IconName::Close)
|
.icon(IconName::Close)
|
||||||
@@ -285,13 +246,17 @@ impl SetupRelay {
|
|||||||
.ghost()
|
.ghost()
|
||||||
.invisible()
|
.invisible()
|
||||||
.group_hover("", |this| this.visible())
|
.group_hover("", |this| this.visible())
|
||||||
.on_click(cx.listener(move |this, _, window, cx| {
|
.on_click({
|
||||||
this.remove(ix, window, cx)
|
let url = url.to_owned();
|
||||||
})),
|
cx.listener(move |this, _ev, _window, cx| {
|
||||||
|
this.remove(&url, cx);
|
||||||
|
})
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
items
|
items
|
||||||
}),
|
}),
|
||||||
@@ -339,39 +304,6 @@ impl Render for SetupRelay {
|
|||||||
})),
|
})),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.child(
|
|
||||||
h_flex()
|
|
||||||
.gap_2()
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.text_xs()
|
|
||||||
.font_semibold()
|
|
||||||
.text_color(cx.theme().text_muted)
|
|
||||||
.child(shared_t!("common.recommended")),
|
|
||||||
)
|
|
||||||
.child(h_flex().gap_1().children({
|
|
||||||
NIP17_RELAYS.iter().map(|&relay| {
|
|
||||||
div()
|
|
||||||
.id(relay)
|
|
||||||
.group("")
|
|
||||||
.py_0p5()
|
|
||||||
.px_1p5()
|
|
||||||
.text_xs()
|
|
||||||
.text_center()
|
|
||||||
.bg(cx.theme().secondary_background)
|
|
||||||
.hover(|this| this.bg(cx.theme().secondary_hover))
|
|
||||||
.active(|this| this.bg(cx.theme().secondary_active))
|
|
||||||
.rounded_full()
|
|
||||||
.child(relay)
|
|
||||||
.on_click(cx.listener(move |this, _, window, cx| {
|
|
||||||
this.input.update(cx, |this, cx| {
|
|
||||||
this.set_value(relay, window, cx);
|
|
||||||
});
|
|
||||||
this.add(window, cx);
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
.when_some(self.error.as_ref(), |this, error| {
|
.when_some(self.error.as_ref(), |this, error| {
|
||||||
this.child(
|
this.child(
|
||||||
div()
|
div()
|
||||||
|
|||||||
@@ -0,0 +1,318 @@
|
|||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use common::{RenderedProfile, BUNKER_TIMEOUT};
|
||||||
|
use gpui::prelude::FluentBuilder;
|
||||||
|
use gpui::{
|
||||||
|
div, relative, rems, svg, AnyElement, App, AppContext, Context, Entity, EventEmitter,
|
||||||
|
FocusHandle, Focusable, InteractiveElement, IntoElement, ParentElement, Render,
|
||||||
|
RetainAllImageCache, SharedString, StatefulInteractiveElement, Styled, Subscription, Task,
|
||||||
|
Window,
|
||||||
|
};
|
||||||
|
use i18n::{shared_t, t};
|
||||||
|
use key_store::{Credential, KeyItem, KeyStore};
|
||||||
|
use nostr_connect::prelude::*;
|
||||||
|
use person::PersonRegistry;
|
||||||
|
use smallvec::{smallvec, SmallVec};
|
||||||
|
use state::NostrRegistry;
|
||||||
|
use theme::ActiveTheme;
|
||||||
|
use ui::avatar::Avatar;
|
||||||
|
use ui::button::{Button, ButtonVariants};
|
||||||
|
use ui::dock_area::panel::{Panel, PanelEvent};
|
||||||
|
use ui::indicator::Indicator;
|
||||||
|
use ui::{h_flex, v_flex, ContextModal, Sizable, StyledExt};
|
||||||
|
|
||||||
|
use crate::actions::{reset, CoopAuthUrlHandler};
|
||||||
|
|
||||||
|
pub fn init(cre: Credential, window: &mut Window, cx: &mut App) -> Entity<Startup> {
|
||||||
|
cx.new(|cx| Startup::new(cre, window, cx))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Startup
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Startup {
|
||||||
|
credential: Credential,
|
||||||
|
loading: bool,
|
||||||
|
|
||||||
|
name: SharedString,
|
||||||
|
focus_handle: FocusHandle,
|
||||||
|
image_cache: Entity<RetainAllImageCache>,
|
||||||
|
|
||||||
|
/// Event subscriptions
|
||||||
|
_subscriptions: SmallVec<[Subscription; 1]>,
|
||||||
|
|
||||||
|
/// Background tasks
|
||||||
|
_tasks: SmallVec<[Task<()>; 1]>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Startup {
|
||||||
|
fn new(credential: Credential, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||||
|
let tasks = smallvec![];
|
||||||
|
let mut subscriptions = smallvec![];
|
||||||
|
|
||||||
|
subscriptions.push(
|
||||||
|
// Clear the local state when user closes the account panel
|
||||||
|
cx.on_release_in(window, move |this, window, cx| {
|
||||||
|
this.image_cache.update(cx, |this, cx| {
|
||||||
|
this.clear(window, cx);
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
credential,
|
||||||
|
loading: false,
|
||||||
|
name: "Onboarding".into(),
|
||||||
|
focus_handle: cx.focus_handle(),
|
||||||
|
image_cache: RetainAllImageCache::new(cx),
|
||||||
|
_subscriptions: subscriptions,
|
||||||
|
_tasks: tasks,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn login(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
self.set_loading(true, cx);
|
||||||
|
|
||||||
|
let secret = self.credential.secret();
|
||||||
|
|
||||||
|
// Try to login with bunker
|
||||||
|
if secret.starts_with("bunker://") {
|
||||||
|
match NostrConnectUri::parse(secret) {
|
||||||
|
Ok(uri) => {
|
||||||
|
self.login_with_bunker(uri, window, cx);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
window.push_notification(e.to_string(), cx);
|
||||||
|
self.set_loading(false, cx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fall back to login with keys
|
||||||
|
match SecretKey::parse(secret) {
|
||||||
|
Ok(secret) => {
|
||||||
|
self.login_with_keys(secret, cx);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
window.push_notification(e.to_string(), cx);
|
||||||
|
self.set_loading(false, cx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn login_with_bunker(
|
||||||
|
&mut self,
|
||||||
|
uri: NostrConnectUri,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) {
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let client = nostr.read(cx).client();
|
||||||
|
let keystore = KeyStore::global(cx).read(cx).backend();
|
||||||
|
|
||||||
|
// Handle connection in the background
|
||||||
|
cx.spawn_in(window, async move |this, cx| {
|
||||||
|
let result = keystore
|
||||||
|
.read_credentials(&KeyItem::Bunker.to_string(), cx)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
this.update_in(cx, |this, window, cx| {
|
||||||
|
match result {
|
||||||
|
Ok(Some((_, content))) => {
|
||||||
|
let secret = SecretKey::from_slice(&content).unwrap();
|
||||||
|
let keys = Keys::new(secret);
|
||||||
|
let timeout = Duration::from_secs(BUNKER_TIMEOUT);
|
||||||
|
let mut signer = NostrConnect::new(uri, keys, timeout, None).unwrap();
|
||||||
|
|
||||||
|
// Handle auth url with the default browser
|
||||||
|
signer.auth_url_handler(CoopAuthUrlHandler);
|
||||||
|
|
||||||
|
// Connect to the remote signer
|
||||||
|
this._tasks.push(
|
||||||
|
// Handle connection in the background
|
||||||
|
cx.spawn_in(window, async move |this, cx| {
|
||||||
|
match signer.bunker_uri().await {
|
||||||
|
Ok(_) => {
|
||||||
|
client.set_signer(signer).await;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
this.update_in(cx, |this, window, cx| {
|
||||||
|
window.push_notification(e.to_string(), cx);
|
||||||
|
this.set_loading(false, cx);
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Ok(None) => {
|
||||||
|
window.push_notification(t!("login.keyring_required"), cx);
|
||||||
|
this.set_loading(false, cx);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
window.push_notification(e.to_string(), cx);
|
||||||
|
this.set_loading(false, cx);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn login_with_keys(&mut self, secret: SecretKey, cx: &mut Context<Self>) {
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let client = nostr.read(cx).client();
|
||||||
|
let keys = Keys::new(secret);
|
||||||
|
|
||||||
|
// Update the signer
|
||||||
|
cx.background_spawn(async move {
|
||||||
|
client.set_signer(keys).await;
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
|
||||||
|
self.loading = status;
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Panel for Startup {
|
||||||
|
fn panel_id(&self) -> SharedString {
|
||||||
|
self.name.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn title(&self, _cx: &App) -> AnyElement {
|
||||||
|
self.name.clone().into_any_element()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EventEmitter<PanelEvent> for Startup {}
|
||||||
|
|
||||||
|
impl Focusable for Startup {
|
||||||
|
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
|
||||||
|
self.focus_handle.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for Startup {
|
||||||
|
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
let persons = PersonRegistry::global(cx);
|
||||||
|
let bunker = self.credential.secret().starts_with("bunker://");
|
||||||
|
let profile = persons
|
||||||
|
.read(cx)
|
||||||
|
.get_person(&self.credential.public_key(), cx);
|
||||||
|
|
||||||
|
v_flex()
|
||||||
|
.image_cache(self.image_cache.clone())
|
||||||
|
.relative()
|
||||||
|
.size_full()
|
||||||
|
.gap_10()
|
||||||
|
.items_center()
|
||||||
|
.justify_center()
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.items_center()
|
||||||
|
.justify_center()
|
||||||
|
.gap_4()
|
||||||
|
.child(
|
||||||
|
svg()
|
||||||
|
.path("brand/coop.svg")
|
||||||
|
.size_16()
|
||||||
|
.text_color(cx.theme().elevated_surface_background),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_center()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_xl()
|
||||||
|
.font_semibold()
|
||||||
|
.line_height(relative(1.3))
|
||||||
|
.child(shared_t!("welcome.title")),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_color(cx.theme().text_muted)
|
||||||
|
.child(shared_t!("welcome.subtitle")),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.gap_2()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.id("account")
|
||||||
|
.h_10()
|
||||||
|
.w_72()
|
||||||
|
.bg(cx.theme().elevated_surface_background)
|
||||||
|
.rounded_lg()
|
||||||
|
.text_sm()
|
||||||
|
.when(self.loading, |this| {
|
||||||
|
this.child(
|
||||||
|
div()
|
||||||
|
.size_full()
|
||||||
|
.flex()
|
||||||
|
.items_center()
|
||||||
|
.justify_center()
|
||||||
|
.child(Indicator::new().small()),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.when(!self.loading, |this| {
|
||||||
|
let avatar = profile.avatar(true);
|
||||||
|
let name = profile.display_name();
|
||||||
|
|
||||||
|
this.child(
|
||||||
|
h_flex()
|
||||||
|
.h_full()
|
||||||
|
.justify_center()
|
||||||
|
.gap_2()
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.gap_1()
|
||||||
|
.child(Avatar::new(avatar).size(rems(1.5)))
|
||||||
|
.child(div().pb_px().font_semibold().child(name)),
|
||||||
|
)
|
||||||
|
.child(div().when(bunker, |this| {
|
||||||
|
let label = SharedString::from("Nostr Connect");
|
||||||
|
|
||||||
|
this.child(
|
||||||
|
div()
|
||||||
|
.py_0p5()
|
||||||
|
.px_2()
|
||||||
|
.text_xs()
|
||||||
|
.bg(cx.theme().secondary_active)
|
||||||
|
.text_color(cx.theme().secondary_foreground)
|
||||||
|
.rounded_full()
|
||||||
|
.child(label),
|
||||||
|
)
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.text_color(cx.theme().text)
|
||||||
|
.active(|this| {
|
||||||
|
this.text_color(cx.theme().element_foreground)
|
||||||
|
.bg(cx.theme().element_active)
|
||||||
|
})
|
||||||
|
.hover(|this| {
|
||||||
|
this.text_color(cx.theme().element_foreground)
|
||||||
|
.bg(cx.theme().element_hover)
|
||||||
|
})
|
||||||
|
.on_click(cx.listener(move |this, _e, window, cx| {
|
||||||
|
this.login(window, cx);
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
Button::new("logout")
|
||||||
|
.label(t!("user.sign_out"))
|
||||||
|
.ghost()
|
||||||
|
.on_click(|_, _window, cx| {
|
||||||
|
reset(cx);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,9 +4,7 @@ use gpui::{
|
|||||||
StatefulInteractiveElement, Styled, Window,
|
StatefulInteractiveElement, Styled, Window,
|
||||||
};
|
};
|
||||||
use theme::ActiveTheme;
|
use theme::ActiveTheme;
|
||||||
use ui::button::Button;
|
|
||||||
use ui::dock_area::panel::{Panel, PanelEvent};
|
use ui::dock_area::panel::{Panel, PanelEvent};
|
||||||
use ui::popup_menu::PopupMenu;
|
|
||||||
use ui::{v_flex, StyledExt};
|
use ui::{v_flex, StyledExt};
|
||||||
|
|
||||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Welcome> {
|
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Welcome> {
|
||||||
@@ -50,14 +48,6 @@ impl Panel for Welcome {
|
|||||||
)
|
)
|
||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn popup_menu(&self, menu: PopupMenu, _cx: &App) -> PopupMenu {
|
|
||||||
menu.track_focus(&self.focus_handle)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn toolbar_buttons(&self, _window: &Window, _cx: &App) -> Vec<Button> {
|
|
||||||
vec![]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EventEmitter<PanelEvent> for Welcome {}
|
impl EventEmitter<PanelEvent> for Welcome {}
|
||||||
@@ -69,7 +59,7 @@ impl Focusable for Welcome {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Render for Welcome {
|
impl Render for Welcome {
|
||||||
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
|
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
div()
|
div()
|
||||||
.size_full()
|
.size_full()
|
||||||
.flex()
|
.flex()
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
[package]
|
||||||
|
name = "encryption"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
state = { path = "../state" }
|
||||||
|
common = { path = "../common" }
|
||||||
|
account = { path = "../account" }
|
||||||
|
|
||||||
|
gpui.workspace = true
|
||||||
|
nostr-sdk.workspace = true
|
||||||
|
|
||||||
|
anyhow.workspace = true
|
||||||
|
smallvec.workspace = true
|
||||||
|
smol.workspace = true
|
||||||
|
futures.workspace = true
|
||||||
|
flume.workspace = true
|
||||||
|
log.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
@@ -0,0 +1,682 @@
|
|||||||
|
use std::collections::HashSet;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use account::Account;
|
||||||
|
use anyhow::{anyhow, Context as AnyhowContext, Error};
|
||||||
|
use common::app_name;
|
||||||
|
use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task};
|
||||||
|
use nostr_sdk::prelude::*;
|
||||||
|
pub use signer::*;
|
||||||
|
use smallvec::{smallvec, SmallVec};
|
||||||
|
use state::{Announcement, NostrRegistry};
|
||||||
|
|
||||||
|
mod signer;
|
||||||
|
|
||||||
|
pub fn init(cx: &mut App) {
|
||||||
|
Encryption::set_global(cx.new(Encryption::new), cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
struct GlobalEncryption(Entity<Encryption>);
|
||||||
|
|
||||||
|
impl Global for GlobalEncryption {}
|
||||||
|
|
||||||
|
pub struct Encryption {
|
||||||
|
/// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md
|
||||||
|
///
|
||||||
|
/// Client Signer that used for communication between devices
|
||||||
|
client_signer: Entity<Option<Arc<dyn NostrSigner>>>,
|
||||||
|
|
||||||
|
/// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md
|
||||||
|
///
|
||||||
|
/// Encryption Key used for encryption and decryption instead of the user's identity
|
||||||
|
pub encryption: Entity<Option<Arc<dyn NostrSigner>>>,
|
||||||
|
|
||||||
|
/// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md
|
||||||
|
///
|
||||||
|
/// Encryption Key announcement
|
||||||
|
announcement: Option<Arc<Announcement>>,
|
||||||
|
|
||||||
|
/// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md
|
||||||
|
///
|
||||||
|
/// Requests for encryption keys from other devices
|
||||||
|
requests: Entity<HashSet<Announcement>>,
|
||||||
|
|
||||||
|
/// Async task for handling notifications
|
||||||
|
handle_notifications: Option<Task<()>>,
|
||||||
|
|
||||||
|
/// Async task for handling requests
|
||||||
|
handle_requests: Option<Task<()>>,
|
||||||
|
|
||||||
|
/// Event subscriptions
|
||||||
|
_subscriptions: SmallVec<[Subscription; 2]>,
|
||||||
|
|
||||||
|
/// Tasks for asynchronous operations
|
||||||
|
_tasks: SmallVec<[Task<()>; 1]>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Encryption {
|
||||||
|
/// Retrieve the global encryption state
|
||||||
|
pub fn global(cx: &App) -> Entity<Self> {
|
||||||
|
cx.global::<GlobalEncryption>().0.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the global encryption instance
|
||||||
|
fn set_global(state: Entity<Self>, cx: &mut App) {
|
||||||
|
cx.set_global(GlobalEncryption(state));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new encryption instance
|
||||||
|
fn new(cx: &mut Context<Self>) -> Self {
|
||||||
|
let account = Account::global(cx);
|
||||||
|
|
||||||
|
let requests = cx.new(|_| HashSet::default());
|
||||||
|
let encryption = cx.new(|_| None);
|
||||||
|
let client_signer = cx.new(|_| None);
|
||||||
|
|
||||||
|
let mut subscriptions = smallvec![];
|
||||||
|
|
||||||
|
subscriptions.push(
|
||||||
|
// Observe the account state
|
||||||
|
cx.observe(&account, |this, state, cx| {
|
||||||
|
if state.read(cx).has_account() && this.client_signer.read(cx).is_none() {
|
||||||
|
this.get_client(cx);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
subscriptions.push(
|
||||||
|
// Observe the client signer state
|
||||||
|
cx.observe(&client_signer, |this, state, cx| {
|
||||||
|
if state.read(cx).is_some() {
|
||||||
|
this.get_announcement(cx);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
subscriptions.push(
|
||||||
|
// Observe the encryption signer state
|
||||||
|
cx.observe(&encryption, |this, state, cx| {
|
||||||
|
if state.read(cx).is_some() {
|
||||||
|
this._tasks.push(this.resubscribe_messages(cx));
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
requests,
|
||||||
|
client_signer,
|
||||||
|
encryption,
|
||||||
|
announcement: None,
|
||||||
|
handle_notifications: None,
|
||||||
|
handle_requests: None,
|
||||||
|
_subscriptions: subscriptions,
|
||||||
|
_tasks: smallvec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encrypt and store a key in the local database.
|
||||||
|
async fn set_keys<T>(client: &Client, kind: T, value: String) -> Result<(), Error>
|
||||||
|
where
|
||||||
|
T: Into<String>,
|
||||||
|
{
|
||||||
|
let signer = client.signer().await?;
|
||||||
|
let public_key = signer.get_public_key().await?;
|
||||||
|
|
||||||
|
// Encrypt the value
|
||||||
|
let content = signer.nip44_encrypt(&public_key, value.as_ref()).await?;
|
||||||
|
|
||||||
|
// Construct the application data event
|
||||||
|
let event = EventBuilder::new(Kind::ApplicationSpecificData, content)
|
||||||
|
.tag(Tag::identifier(format!("coop:{}", kind.into())))
|
||||||
|
.build(public_key)
|
||||||
|
.sign(&Keys::generate())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Save the event to the database
|
||||||
|
client.database().save_event(&event).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get and decrypt a key from the local database.
|
||||||
|
async fn get_keys<T>(client: &Client, kind: T) -> Result<Keys, Error>
|
||||||
|
where
|
||||||
|
T: Into<String>,
|
||||||
|
{
|
||||||
|
let signer = client.signer().await?;
|
||||||
|
let public_key = signer.get_public_key().await?;
|
||||||
|
|
||||||
|
let filter = Filter::new()
|
||||||
|
.kind(Kind::ApplicationSpecificData)
|
||||||
|
.identifier(format!("coop:{}", kind.into()));
|
||||||
|
|
||||||
|
if let Some(event) = client.database().query(filter).await?.first() {
|
||||||
|
let content = signer.nip44_decrypt(&public_key, &event.content).await?;
|
||||||
|
let secret = SecretKey::parse(&content)?;
|
||||||
|
let keys = Keys::new(secret);
|
||||||
|
|
||||||
|
Ok(keys)
|
||||||
|
} else {
|
||||||
|
Err(anyhow!("Key not found"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the client keys from the database
|
||||||
|
fn get_client(&mut self, cx: &mut Context<Self>) {
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let client = nostr.read(cx).client();
|
||||||
|
|
||||||
|
self._tasks.push(
|
||||||
|
// Run in the main thread
|
||||||
|
cx.spawn(async move |this, cx| {
|
||||||
|
match Self::get_keys(&client, "client").await {
|
||||||
|
Ok(keys) => {
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.set_client(Arc::new(keys), cx);
|
||||||
|
})
|
||||||
|
.expect("Entity has been released");
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
let keys = Keys::generate();
|
||||||
|
let secret = keys.secret_key().to_secret_hex();
|
||||||
|
|
||||||
|
// Store the key in the database for future use
|
||||||
|
Self::set_keys(&client, "client", secret).await.ok();
|
||||||
|
|
||||||
|
// Update global state
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.set_client(Arc::new(keys), cx);
|
||||||
|
})
|
||||||
|
.expect("Entity has been released");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the announcement from the database
|
||||||
|
fn get_announcement(&mut self, cx: &mut Context<Self>) {
|
||||||
|
let task = self._get_announcement(cx);
|
||||||
|
let delay = Duration::from_secs(5);
|
||||||
|
|
||||||
|
self._tasks.push(
|
||||||
|
// Run task in the background
|
||||||
|
cx.spawn(async move |this, cx| {
|
||||||
|
cx.background_executor().timer(delay).await;
|
||||||
|
|
||||||
|
if let Ok(announcement) = task.await {
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.load_encryption(&announcement, cx);
|
||||||
|
// Set the announcement
|
||||||
|
this.announcement = Some(Arc::new(announcement));
|
||||||
|
cx.notify();
|
||||||
|
})
|
||||||
|
.expect("Entity has been released");
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn _get_announcement(&self, cx: &App) -> Task<Result<Announcement, Error>> {
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let client = nostr.read(cx).client();
|
||||||
|
|
||||||
|
cx.background_spawn(async move {
|
||||||
|
let user_signer = client.signer().await?;
|
||||||
|
let public_key = user_signer.get_public_key().await?;
|
||||||
|
|
||||||
|
let filter = Filter::new()
|
||||||
|
.kind(Kind::Custom(10044))
|
||||||
|
.author(public_key)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if let Some(event) = client.database().query(filter).await?.first() {
|
||||||
|
Ok(NostrRegistry::extract_announcement(event)?)
|
||||||
|
} else {
|
||||||
|
Err(anyhow!("Announcement not found"))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load the encryption key that stored in the database
|
||||||
|
///
|
||||||
|
/// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md
|
||||||
|
fn load_encryption(&mut self, announcement: &Announcement, cx: &mut Context<Self>) {
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let client = nostr.read(cx).client();
|
||||||
|
let n = announcement.public_key();
|
||||||
|
|
||||||
|
cx.spawn(async move |this, cx| {
|
||||||
|
let result = Self::get_keys(&client, "encryption").await;
|
||||||
|
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
if let Ok(keys) = result {
|
||||||
|
if keys.public_key() == n {
|
||||||
|
this.set_encryption(Arc::new(keys), cx);
|
||||||
|
this.listen_request(cx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.load_response(cx);
|
||||||
|
})
|
||||||
|
.expect("Entity has been released");
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_response(&mut self, cx: &mut Context<Self>) {
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let client = nostr.read(cx).client();
|
||||||
|
|
||||||
|
// Get the client signer
|
||||||
|
let Some(client_signer) = self.client_signer.read(cx).clone() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let task: Task<Result<Keys, Error>> = cx.background_spawn(async move {
|
||||||
|
let signer = client.signer().await?;
|
||||||
|
let public_key = signer.get_public_key().await?;
|
||||||
|
|
||||||
|
let filter = Filter::new()
|
||||||
|
.author(public_key)
|
||||||
|
.kind(Kind::Custom(4455))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if let Some(event) = client.database().query(filter).await?.first_owned() {
|
||||||
|
let response = NostrRegistry::extract_response(&client, &event).await?;
|
||||||
|
|
||||||
|
// Decrypt the payload using the client signer
|
||||||
|
let decrypted = client_signer
|
||||||
|
.nip44_decrypt(&response.public_key(), response.payload())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Construct the encryption keys
|
||||||
|
let secret = SecretKey::parse(&decrypted)?;
|
||||||
|
let keys = Keys::new(secret);
|
||||||
|
|
||||||
|
return Ok(keys);
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(anyhow!("not found"))
|
||||||
|
});
|
||||||
|
|
||||||
|
cx.spawn(async move |this, cx| {
|
||||||
|
match task.await {
|
||||||
|
Ok(keys) => {
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.set_encryption(Arc::new(keys), cx);
|
||||||
|
})
|
||||||
|
.expect("Entity has been released");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("Failed to load encryption response: {e}");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Listen for the encryption key request from other devices
|
||||||
|
///
|
||||||
|
/// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md
|
||||||
|
pub fn listen_request(&mut self, cx: &mut Context<Self>) {
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let client = nostr.read(cx).client();
|
||||||
|
|
||||||
|
let (tx, rx) = flume::bounded::<Announcement>(50);
|
||||||
|
|
||||||
|
let task: Task<Result<(), Error>> = cx.background_spawn({
|
||||||
|
let client = nostr.read(cx).client();
|
||||||
|
|
||||||
|
async move {
|
||||||
|
let signer = client.signer().await?;
|
||||||
|
let public_key = signer.get_public_key().await?;
|
||||||
|
let id = SubscriptionId::new("listen-request");
|
||||||
|
|
||||||
|
let filter = Filter::new()
|
||||||
|
.author(public_key)
|
||||||
|
.kind(Kind::Custom(4454))
|
||||||
|
.since(Timestamp::now());
|
||||||
|
|
||||||
|
// Unsubscribe from the previous subscription
|
||||||
|
client.unsubscribe(&id).await;
|
||||||
|
|
||||||
|
// Subscribe to the new subscription
|
||||||
|
client.subscribe_with_id(id, filter, None).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Run this task and finish in the background
|
||||||
|
task.detach();
|
||||||
|
|
||||||
|
// Handle notifications
|
||||||
|
self.handle_notifications = Some(cx.background_spawn(async move {
|
||||||
|
let mut notifications = client.notifications();
|
||||||
|
let mut processed_events = HashSet::new();
|
||||||
|
|
||||||
|
while let Ok(notification) = notifications.recv().await {
|
||||||
|
let RelayPoolNotification::Message { message, .. } = notification else {
|
||||||
|
// Skip if the notification is not a message
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
if let RelayMessage::Event { event, .. } = message {
|
||||||
|
if !processed_events.insert(event.id) {
|
||||||
|
// Skip if the event has already been processed
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if event.kind != Kind::Custom(4454) {
|
||||||
|
// Skip if the event is not a encryption events
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
if NostrRegistry::is_self_authored(&client, &event).await {
|
||||||
|
if let Ok(announcement) = NostrRegistry::extract_announcement(&event) {
|
||||||
|
tx.send_async(announcement).await.ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Handle requests
|
||||||
|
self.handle_requests = Some(cx.spawn(async move |this, cx| {
|
||||||
|
while let Ok(request) = rx.recv_async().await {
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.set_request(request, cx);
|
||||||
|
})
|
||||||
|
.expect("Entity has been released");
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Overwrite the encryption key
|
||||||
|
///
|
||||||
|
/// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md
|
||||||
|
pub fn new_encryption(&self, cx: &App) -> Task<Result<Keys, Error>> {
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let client = nostr.read(cx).client();
|
||||||
|
let gossip = nostr.read(cx).gossip();
|
||||||
|
|
||||||
|
let keys = Keys::generate();
|
||||||
|
let public_key = keys.public_key();
|
||||||
|
let secret = keys.secret_key().to_secret_hex();
|
||||||
|
|
||||||
|
// Create a task announce the encryption key
|
||||||
|
cx.background_spawn(async move {
|
||||||
|
// Store the encryption key to the database
|
||||||
|
Self::set_keys(&client, "encryption", secret).await?;
|
||||||
|
|
||||||
|
let signer = client.signer().await?;
|
||||||
|
let signer_pubkey = signer.get_public_key().await?;
|
||||||
|
let gossip = gossip.read().await;
|
||||||
|
let write_relays = gossip.outbox_relays(&signer_pubkey);
|
||||||
|
|
||||||
|
// Ensure connections to the write relays
|
||||||
|
gossip.ensure_connections(&client, &write_relays).await;
|
||||||
|
|
||||||
|
// Construct the announcement event
|
||||||
|
let event = EventBuilder::new(Kind::Custom(10044), "")
|
||||||
|
.tags(vec![
|
||||||
|
Tag::client(app_name()),
|
||||||
|
Tag::custom(TagKind::custom("n"), vec![public_key]),
|
||||||
|
])
|
||||||
|
.build(signer_pubkey)
|
||||||
|
.sign(&signer)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Send the announcement event to user's relays
|
||||||
|
client.send_event_to(write_relays, &event).await?;
|
||||||
|
|
||||||
|
Ok(keys)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a request for encryption key from other clients
|
||||||
|
///
|
||||||
|
/// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md
|
||||||
|
pub fn send_request(&self, cx: &App) -> Task<Result<Option<Keys>, Error>> {
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let client = nostr.read(cx).client();
|
||||||
|
let gossip = nostr.read(cx).gossip();
|
||||||
|
|
||||||
|
// Get the client signer
|
||||||
|
let Some(client_signer) = self.client_signer.read(cx).clone() else {
|
||||||
|
return Task::ready(Err(anyhow!("Client Signer is required")));
|
||||||
|
};
|
||||||
|
|
||||||
|
cx.background_spawn(async move {
|
||||||
|
let signer = client.signer().await?;
|
||||||
|
let public_key = signer.get_public_key().await?;
|
||||||
|
let client_pubkey = client_signer.get_public_key().await?;
|
||||||
|
|
||||||
|
// Get the encryption key approval response from the database first
|
||||||
|
let filter = Filter::new()
|
||||||
|
.kind(Kind::Custom(4455))
|
||||||
|
.author(public_key)
|
||||||
|
.pubkey(client_pubkey)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
match client.database().query(filter).await?.first_owned() {
|
||||||
|
Some(event) => {
|
||||||
|
let root_device = event
|
||||||
|
.tags
|
||||||
|
.find(TagKind::custom("P"))
|
||||||
|
.and_then(|tag| tag.content())
|
||||||
|
.and_then(|content| PublicKey::parse(content).ok())
|
||||||
|
.context("Invalid event's tags")?;
|
||||||
|
|
||||||
|
let payload = event.content.as_str();
|
||||||
|
let decrypted = client_signer.nip44_decrypt(&root_device, payload).await?;
|
||||||
|
|
||||||
|
let secret = SecretKey::from_hex(&decrypted)?;
|
||||||
|
let keys = Keys::new(secret);
|
||||||
|
|
||||||
|
Ok(Some(keys))
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
let gossip = gossip.read().await;
|
||||||
|
let write_relays = gossip.outbox_relays(&public_key);
|
||||||
|
|
||||||
|
// Ensure connections to the write relays
|
||||||
|
gossip.ensure_connections(&client, &write_relays).await;
|
||||||
|
|
||||||
|
// Construct encryption keys request event
|
||||||
|
let event = EventBuilder::new(Kind::Custom(4454), "")
|
||||||
|
.tags(vec![
|
||||||
|
Tag::client(app_name()),
|
||||||
|
Tag::custom(TagKind::custom("pubkey"), vec![client_pubkey]),
|
||||||
|
])
|
||||||
|
.sign(&signer)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Send a request for encryption keys from other devices
|
||||||
|
client.send_event_to(&write_relays, &event).await?;
|
||||||
|
|
||||||
|
// Create a unique ID to control the subscription later
|
||||||
|
let subscription_id = SubscriptionId::new("listen-response");
|
||||||
|
|
||||||
|
let filter = Filter::new()
|
||||||
|
.kind(Kind::Custom(4455))
|
||||||
|
.author(public_key)
|
||||||
|
.since(Timestamp::now());
|
||||||
|
|
||||||
|
// Subscribe to the approval response event
|
||||||
|
client
|
||||||
|
.subscribe_with_id_to(&write_relays, subscription_id, filter, None)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send the approval response event
|
||||||
|
///
|
||||||
|
/// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md
|
||||||
|
pub fn send_response(&self, target: PublicKey, cx: &App) -> Task<Result<(), Error>> {
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let client = nostr.read(cx).client();
|
||||||
|
let gossip = nostr.read(cx).gossip();
|
||||||
|
|
||||||
|
// Get the client signer
|
||||||
|
let Some(client_signer) = self.client_signer.read(cx).clone() else {
|
||||||
|
return Task::ready(Err(anyhow!("Client Signer is required")));
|
||||||
|
};
|
||||||
|
|
||||||
|
cx.background_spawn(async move {
|
||||||
|
let signer = client.signer().await?;
|
||||||
|
let public_key = signer.get_public_key().await?;
|
||||||
|
let gossip = gossip.read().await;
|
||||||
|
let write_relays = gossip.outbox_relays(&public_key);
|
||||||
|
|
||||||
|
// Ensure connections to the write relays
|
||||||
|
gossip.ensure_connections(&client, &write_relays).await;
|
||||||
|
|
||||||
|
let encryption = Self::get_keys(&client, "encryption").await?;
|
||||||
|
let client_pubkey = client_signer.get_public_key().await?;
|
||||||
|
|
||||||
|
// Encrypt the encryption keys with the client's signer
|
||||||
|
let payload = client_signer
|
||||||
|
.nip44_encrypt(&target, &encryption.secret_key().to_secret_hex())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Construct the response event
|
||||||
|
//
|
||||||
|
// P tag: the current client's public key
|
||||||
|
// p tag: the requester's public key
|
||||||
|
let event = EventBuilder::new(Kind::Custom(4455), payload)
|
||||||
|
.tags(vec![
|
||||||
|
Tag::custom(TagKind::custom("P"), vec![client_pubkey]),
|
||||||
|
Tag::public_key(target),
|
||||||
|
])
|
||||||
|
.build(public_key)
|
||||||
|
.sign(&signer)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Send the response event to the user's relay list
|
||||||
|
client.send_event_to(write_relays, &event).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wait for the approval response event
|
||||||
|
///
|
||||||
|
/// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md
|
||||||
|
pub fn wait_for_approval(&self, cx: &App) -> Task<Result<Keys, Error>> {
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let client = nostr.read(cx).client();
|
||||||
|
|
||||||
|
// Get the client signer
|
||||||
|
let Some(client_signer) = self.client_signer.read(cx).clone() else {
|
||||||
|
return Task::ready(Err(anyhow!("Client Signer is required")));
|
||||||
|
};
|
||||||
|
|
||||||
|
cx.background_spawn(async move {
|
||||||
|
let mut notifications = client.notifications();
|
||||||
|
let mut processed_events = HashSet::new();
|
||||||
|
|
||||||
|
while let Ok(notification) = notifications.recv().await {
|
||||||
|
let RelayPoolNotification::Message { message, .. } = notification else {
|
||||||
|
// Skip non-message notifications
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
if let RelayMessage::Event { event, .. } = message {
|
||||||
|
if !processed_events.insert(event.id) {
|
||||||
|
// Skip if the event has already been processed
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if event.kind != Kind::Custom(4455) {
|
||||||
|
// Skip non-response events
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(response) = NostrRegistry::extract_response(&client, &event).await {
|
||||||
|
let public_key = response.public_key();
|
||||||
|
let payload = response.payload();
|
||||||
|
|
||||||
|
// Decrypt the payload using the client signer
|
||||||
|
let decrypted = client_signer.nip44_decrypt(&public_key, payload).await?;
|
||||||
|
let secret = SecretKey::parse(&decrypted)?;
|
||||||
|
// Construct the encryption keys
|
||||||
|
let keys = Keys::new(secret);
|
||||||
|
|
||||||
|
return Ok(keys);
|
||||||
|
} else {
|
||||||
|
log::error!("Failed to extract response from event");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(anyhow!("Failed to handle Encryption Key approval response"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the client signer for the account
|
||||||
|
pub fn set_client(&mut self, signer: Arc<dyn NostrSigner>, cx: &mut Context<Self>) {
|
||||||
|
self.client_signer.update(cx, |this, cx| {
|
||||||
|
*this = Some(signer);
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the encryption signer for the account
|
||||||
|
pub fn set_encryption(&mut self, signer: Arc<dyn NostrSigner>, cx: &mut Context<Self>) {
|
||||||
|
self.encryption.update(cx, |this, cx| {
|
||||||
|
*this = Some(signer);
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if the account entity has an encryption key
|
||||||
|
pub fn has_encryption(&self, cx: &App) -> bool {
|
||||||
|
self.encryption.read(cx).is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the encryption key
|
||||||
|
pub fn encryption_key(&self, cx: &App) -> Option<Arc<dyn NostrSigner>> {
|
||||||
|
self.encryption.read(cx).clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the encryption announcement
|
||||||
|
pub fn announcement(&self) -> Option<Arc<Announcement>> {
|
||||||
|
self.announcement.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the encryption requests
|
||||||
|
pub fn requests(&self) -> Entity<HashSet<Announcement>> {
|
||||||
|
self.requests.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Push the encryption request
|
||||||
|
pub fn set_request(&mut self, request: Announcement, cx: &mut Context<Self>) {
|
||||||
|
self.requests.update(cx, |this, cx| {
|
||||||
|
this.insert(request);
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resubscribe to gift wrap events
|
||||||
|
fn resubscribe_messages(&self, cx: &App) -> Task<()> {
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let client = nostr.read(cx).client();
|
||||||
|
let gossip = nostr.read(cx).gossip();
|
||||||
|
|
||||||
|
let account = Account::global(cx);
|
||||||
|
let public_key = account.read(cx).public_key();
|
||||||
|
|
||||||
|
cx.background_spawn(async move {
|
||||||
|
let gossip = gossip.read().await;
|
||||||
|
let relays = gossip.messaging_relays(&public_key);
|
||||||
|
|
||||||
|
NostrRegistry::get_messages(&client, public_key, &relays).await;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Deserialize, Serialize)]
|
||||||
|
pub enum SignerKind {
|
||||||
|
Encryption,
|
||||||
|
#[default]
|
||||||
|
User,
|
||||||
|
Auto,
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
[package]
|
||||||
|
name = "encryption_ui"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
state = { path = "../state" }
|
||||||
|
ui = { path = "../ui" }
|
||||||
|
theme = { path = "../theme" }
|
||||||
|
common = { path = "../common" }
|
||||||
|
account = { path = "../account" }
|
||||||
|
encryption = { path = "../encryption" }
|
||||||
|
person = { path = "../person" }
|
||||||
|
settings = { path = "../settings" }
|
||||||
|
|
||||||
|
gpui.workspace = true
|
||||||
|
|
||||||
|
nostr-sdk.workspace = true
|
||||||
|
anyhow.workspace = true
|
||||||
|
itertools.workspace = true
|
||||||
|
smallvec.workspace = true
|
||||||
|
smol.workspace = true
|
||||||
|
log.workspace = true
|
||||||
|
futures.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
@@ -0,0 +1,464 @@
|
|||||||
|
use std::cell::Cell;
|
||||||
|
use std::rc::Rc;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use anyhow::anyhow;
|
||||||
|
use common::shorten_pubkey;
|
||||||
|
use encryption::Encryption;
|
||||||
|
use futures::FutureExt;
|
||||||
|
use gpui::prelude::FluentBuilder;
|
||||||
|
use gpui::{
|
||||||
|
div, px, App, AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString,
|
||||||
|
Styled, Subscription, Window,
|
||||||
|
};
|
||||||
|
use smallvec::{smallvec, SmallVec};
|
||||||
|
use state::Announcement;
|
||||||
|
use theme::ActiveTheme;
|
||||||
|
use ui::button::{Button, ButtonVariants};
|
||||||
|
use ui::notification::Notification;
|
||||||
|
use ui::{h_flex, v_flex, ContextModal, Disableable, Icon, IconName, Sizable, StyledExt};
|
||||||
|
|
||||||
|
pub fn init(window: &mut Window, cx: &mut App) -> Entity<EncryptionPanel> {
|
||||||
|
cx.new(|cx| EncryptionPanel::new(window, cx))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct EncryptionPanel {
|
||||||
|
/// Whether the panel is currently requesting encryption.
|
||||||
|
requesting: bool,
|
||||||
|
|
||||||
|
/// Whether the panel is currently creating encryption.
|
||||||
|
creating: bool,
|
||||||
|
|
||||||
|
/// Whether the panel is currently showing an error.
|
||||||
|
error: Entity<Option<SharedString>>,
|
||||||
|
|
||||||
|
/// Event subscriptions
|
||||||
|
_subscriptions: SmallVec<[Subscription; 1]>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EncryptionPanel {
|
||||||
|
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||||
|
let error = cx.new(|_| None);
|
||||||
|
|
||||||
|
let encryption = Encryption::global(cx);
|
||||||
|
let requests = encryption.read(cx).requests();
|
||||||
|
|
||||||
|
let mut subscriptions = smallvec![];
|
||||||
|
|
||||||
|
subscriptions.push(
|
||||||
|
// Observe encryption request
|
||||||
|
cx.observe_in(&requests, window, |this, state, window, cx| {
|
||||||
|
for req in state.read(cx).clone().into_iter() {
|
||||||
|
this.ask_for_approval(req, window, cx);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
requesting: false,
|
||||||
|
creating: false,
|
||||||
|
error,
|
||||||
|
_subscriptions: subscriptions,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_requesting(&mut self, status: bool, cx: &mut Context<Self>) {
|
||||||
|
self.requesting = status;
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_creating(&mut self, status: bool, cx: &mut Context<Self>) {
|
||||||
|
self.creating = status;
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_error(&mut self, error: impl Into<SharedString>, cx: &mut Context<Self>) {
|
||||||
|
self.error.update(cx, |this, cx| {
|
||||||
|
*this = Some(error.into());
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
let encryption = Encryption::global(cx);
|
||||||
|
let send_request = encryption.read(cx).send_request(cx);
|
||||||
|
|
||||||
|
// Ensure the user has not sent multiple requests
|
||||||
|
if self.requesting {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.set_requesting(true, cx);
|
||||||
|
|
||||||
|
cx.spawn_in(window, async move |this, cx| {
|
||||||
|
match send_request.await {
|
||||||
|
Ok(Some(keys)) => {
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.set_requesting(false, cx);
|
||||||
|
// Set the encryption key
|
||||||
|
encryption.update(cx, |this, cx| {
|
||||||
|
this.set_encryption(Arc::new(keys), cx);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.expect("Entity has been released");
|
||||||
|
}
|
||||||
|
Ok(None) => {
|
||||||
|
this.update_in(cx, |this, window, cx| {
|
||||||
|
this.wait_for_approval(window, cx);
|
||||||
|
})
|
||||||
|
.expect("Entity has been released");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.set_requesting(false, cx);
|
||||||
|
this.set_error(e.to_string(), cx);
|
||||||
|
})
|
||||||
|
.expect("Entity has been released");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn new_encryption(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
let encryption = Encryption::global(cx);
|
||||||
|
let reset = encryption.read(cx).new_encryption(cx);
|
||||||
|
|
||||||
|
// Ensure the user has not sent multiple requests
|
||||||
|
if self.requesting {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.set_creating(true, cx);
|
||||||
|
|
||||||
|
cx.spawn_in(window, async move |this, cx| {
|
||||||
|
match reset.await {
|
||||||
|
Ok(keys) => {
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.set_creating(false, cx);
|
||||||
|
// Set the encryption key
|
||||||
|
encryption.update(cx, |this, cx| {
|
||||||
|
this.set_encryption(Arc::new(keys), cx);
|
||||||
|
this.listen_request(cx);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.expect("Entity has been released");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.set_creating(false, cx);
|
||||||
|
this.set_error(e.to_string(), cx);
|
||||||
|
})
|
||||||
|
.expect("Entity has been released");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wait_for_approval(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
let encryption = Encryption::global(cx);
|
||||||
|
let wait_for_approval = encryption.read(cx).wait_for_approval(cx);
|
||||||
|
|
||||||
|
cx.spawn_in(window, async move |this, cx| {
|
||||||
|
let timeout = cx.background_executor().timer(Duration::from_secs(30));
|
||||||
|
|
||||||
|
let result = futures::select! {
|
||||||
|
result = wait_for_approval.fuse() => {
|
||||||
|
// Ok(keys)
|
||||||
|
result
|
||||||
|
},
|
||||||
|
_ = timeout.fuse() => {
|
||||||
|
Err(anyhow!("Timeout"))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
match result {
|
||||||
|
Ok(keys) => {
|
||||||
|
this.set_requesting(false, cx);
|
||||||
|
// Set the encryption key
|
||||||
|
encryption.update(cx, |this, cx| {
|
||||||
|
this.set_encryption(Arc::new(keys), cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
this.set_requesting(false, cx);
|
||||||
|
this.set_error(e.to_string(), cx);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.expect("Entity has been released");
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ask_for_approval(&mut self, req: Announcement, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
let client_name = req.client_name();
|
||||||
|
let target = req.public_key();
|
||||||
|
let id = SharedString::from(req.id().to_hex());
|
||||||
|
let loading = Rc::new(Cell::new(false));
|
||||||
|
|
||||||
|
let note = Notification::new()
|
||||||
|
.custom_id(id.clone())
|
||||||
|
.autohide(false)
|
||||||
|
.icon(IconName::Encryption)
|
||||||
|
.title(SharedString::from("Encryption Key Request"))
|
||||||
|
.content(move |_window, cx| {
|
||||||
|
v_flex()
|
||||||
|
.gap_2()
|
||||||
|
.text_sm()
|
||||||
|
.child(SharedString::from(
|
||||||
|
"You've requested for the Encryption Key from:",
|
||||||
|
))
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.h_12()
|
||||||
|
.items_center()
|
||||||
|
.justify_center()
|
||||||
|
.px_2()
|
||||||
|
.rounded(cx.theme().radius)
|
||||||
|
.bg(cx.theme().warning_background)
|
||||||
|
.text_color(cx.theme().warning_foreground)
|
||||||
|
.child(client_name.clone()),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.h_7()
|
||||||
|
.w_full()
|
||||||
|
.px_2()
|
||||||
|
.rounded(cx.theme().radius)
|
||||||
|
.bg(cx.theme().elevated_surface_background)
|
||||||
|
.child(SharedString::from(target.to_hex())),
|
||||||
|
)
|
||||||
|
.into_any_element()
|
||||||
|
})
|
||||||
|
.action(move |_window, _cx| {
|
||||||
|
Button::new("approve")
|
||||||
|
.label("Approve")
|
||||||
|
.small()
|
||||||
|
.primary()
|
||||||
|
.loading(loading.get())
|
||||||
|
.disabled(loading.get())
|
||||||
|
.on_click({
|
||||||
|
let loading = Rc::clone(&loading);
|
||||||
|
let id = id.clone();
|
||||||
|
|
||||||
|
move |_ev, window, cx| {
|
||||||
|
// Set loading state to true
|
||||||
|
loading.set(true);
|
||||||
|
|
||||||
|
let encryption = Encryption::global(cx);
|
||||||
|
let send_response = encryption.read(cx).send_response(target, cx);
|
||||||
|
let id = id.clone();
|
||||||
|
|
||||||
|
window
|
||||||
|
.spawn(cx, async move |cx| {
|
||||||
|
let result = send_response.await;
|
||||||
|
|
||||||
|
cx.update(|window, cx| {
|
||||||
|
match result {
|
||||||
|
Ok(_) => {
|
||||||
|
window.clear_notification_by_id(id, cx);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
window.push_notification(e.to_string(), cx);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.expect("Entity has been released");
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
// Push the notification to the current window
|
||||||
|
window.push_notification(note, cx);
|
||||||
|
|
||||||
|
// Focus the window if it's not active
|
||||||
|
if !window.is_window_hovered() {
|
||||||
|
window.activate_window();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for EncryptionPanel {
|
||||||
|
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
const NOTICE: &str = "Found an Encryption Announcement";
|
||||||
|
const SUGGEST: &str = "Please request the Encryption Key to continue using.";
|
||||||
|
|
||||||
|
const DESCRIPTION: &str = "Encryption Key is used to replace the User's Identity in encryption and decryption messages. Coop will automatically fallback to User's Identity if needed.";
|
||||||
|
const WARNING: &str = "Encryption Key is still in the alpha stage. Please be cautious.";
|
||||||
|
|
||||||
|
let encryption = Encryption::global(cx);
|
||||||
|
let announcement = encryption.read(cx).announcement();
|
||||||
|
let has_encryption = encryption.read(cx).has_encryption(cx);
|
||||||
|
|
||||||
|
v_flex()
|
||||||
|
.p_2()
|
||||||
|
.max_w(px(340.))
|
||||||
|
.w(px(340.))
|
||||||
|
.text_sm()
|
||||||
|
.when_some(announcement.as_ref(), |this, announcement| {
|
||||||
|
let pubkey = shorten_pubkey(announcement.public_key(), 16);
|
||||||
|
let client_name = announcement.client_name();
|
||||||
|
|
||||||
|
this.child(
|
||||||
|
v_flex()
|
||||||
|
.gap_2()
|
||||||
|
.when(has_encryption, |this| {
|
||||||
|
this.child(
|
||||||
|
h_flex()
|
||||||
|
.gap_1p5()
|
||||||
|
.text_sm()
|
||||||
|
.font_semibold()
|
||||||
|
.child(
|
||||||
|
Icon::new(IconName::CheckCircle)
|
||||||
|
.text_color(cx.theme().element_foreground)
|
||||||
|
.small(),
|
||||||
|
)
|
||||||
|
.child(SharedString::from("Encryption Key has been set")),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.when(!has_encryption, |this| {
|
||||||
|
this.child(div().font_semibold().child(SharedString::from(NOTICE)))
|
||||||
|
})
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.gap_1()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
|
.font_semibold()
|
||||||
|
.text_color(cx.theme().text_muted)
|
||||||
|
.child(SharedString::from("Client Name:")),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.h_12()
|
||||||
|
.items_center()
|
||||||
|
.justify_center()
|
||||||
|
.rounded(cx.theme().radius)
|
||||||
|
.bg(cx.theme().elevated_surface_background)
|
||||||
|
.child(client_name.clone()),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.gap_1()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
|
.font_semibold()
|
||||||
|
.text_color(cx.theme().text_muted)
|
||||||
|
.child(SharedString::from("Client Public Key:")),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.h_7()
|
||||||
|
.w_full()
|
||||||
|
.px_2()
|
||||||
|
.rounded(cx.theme().radius)
|
||||||
|
.bg(cx.theme().elevated_surface_background)
|
||||||
|
.child(SharedString::from(pubkey)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.when(!has_encryption, |this| {
|
||||||
|
this.child(
|
||||||
|
v_flex()
|
||||||
|
.gap_2()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(cx.theme().text_muted)
|
||||||
|
.child(SharedString::from(SUGGEST)),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.mt_2()
|
||||||
|
.gap_1()
|
||||||
|
.when(!self.requesting, |this| {
|
||||||
|
this.child(
|
||||||
|
Button::new("reset")
|
||||||
|
.label("Reset")
|
||||||
|
.flex_1()
|
||||||
|
.small()
|
||||||
|
.ghost_alt()
|
||||||
|
.loading(self.creating)
|
||||||
|
.disabled(self.creating)
|
||||||
|
.on_click(cx.listener(
|
||||||
|
move |this, _ev, window, cx| {
|
||||||
|
this.new_encryption(window, cx);
|
||||||
|
},
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.when(!self.creating, |this| {
|
||||||
|
this.child(
|
||||||
|
Button::new("request")
|
||||||
|
.label({
|
||||||
|
if self.requesting {
|
||||||
|
"Wait for approval"
|
||||||
|
} else {
|
||||||
|
"Request"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.flex_1()
|
||||||
|
.small()
|
||||||
|
.primary()
|
||||||
|
.loading(self.requesting)
|
||||||
|
.disabled(self.requesting)
|
||||||
|
.on_click(cx.listener(
|
||||||
|
move |this, _ev, window, cx| {
|
||||||
|
this.request(window, cx);
|
||||||
|
},
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.when_none(&announcement, |this| {
|
||||||
|
this.child(
|
||||||
|
v_flex()
|
||||||
|
.gap_2()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.font_semibold()
|
||||||
|
.child(SharedString::from("Set up Encryption Key")),
|
||||||
|
)
|
||||||
|
.child(SharedString::from(DESCRIPTION))
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(cx.theme().warning_foreground)
|
||||||
|
.child(SharedString::from(WARNING)),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
Button::new("create")
|
||||||
|
.label("Setup")
|
||||||
|
.flex_1()
|
||||||
|
.small()
|
||||||
|
.primary()
|
||||||
|
.loading(self.creating)
|
||||||
|
.disabled(self.creating)
|
||||||
|
.on_click(cx.listener(move |this, _ev, window, cx| {
|
||||||
|
this.new_encryption(window, cx);
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.when_some(self.error.read(cx).as_ref(), |this, error| {
|
||||||
|
this.child(
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
|
.text_center()
|
||||||
|
.text_color(cx.theme().danger_foreground)
|
||||||
|
.child(error.clone()),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
pub const APP_NAME: &str = "Coop";
|
|
||||||
pub const APP_ID: &str = "su.reya.coop";
|
|
||||||
pub const APP_PUBKEY: &str = "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDc4MkNFRkQ2RkVGQURGNzUKUldSMTMvcisxdThzZUZraHc4Vno3NVNJek81VkJFUEV3MkJweGFxQXhpekdSU1JIekpqMG4yemMK";
|
|
||||||
pub const APP_UPDATER_ENDPOINT: &str = "https://coop-updater.reya.su/";
|
|
||||||
pub const KEYRING_URL: &str = "Coop Safe Storage";
|
|
||||||
|
|
||||||
pub const ACCOUNT_IDENTIFIER: &str = "coop:user";
|
|
||||||
pub const SETTINGS_IDENTIFIER: &str = "coop:settings";
|
|
||||||
|
|
||||||
/// Bootstrap Relays.
|
|
||||||
pub const BOOTSTRAP_RELAYS: [&str; 5] = [
|
|
||||||
"wss://relay.damus.io",
|
|
||||||
"wss://relay.primal.net",
|
|
||||||
"wss://relay.nos.social",
|
|
||||||
"wss://user.kindpag.es",
|
|
||||||
"wss://purplepag.es",
|
|
||||||
];
|
|
||||||
|
|
||||||
/// Search Relays.
|
|
||||||
pub const SEARCH_RELAYS: [&str; 1] = ["wss://relay.nostr.band"];
|
|
||||||
|
|
||||||
/// NIP65 Relays. Used for new account
|
|
||||||
pub const NIP65_RELAYS: [&str; 4] = [
|
|
||||||
"wss://relay.damus.io",
|
|
||||||
"wss://relay.primal.net",
|
|
||||||
"wss://relay.nostr.net",
|
|
||||||
"wss://nos.lol",
|
|
||||||
];
|
|
||||||
|
|
||||||
/// Messaging Relays. Used for new account
|
|
||||||
pub const NIP17_RELAYS: [&str; 2] = ["wss://nip17.com", "wss://auth.nostr1.com"];
|
|
||||||
|
|
||||||
/// Default relay for Nostr Connect
|
|
||||||
pub const NOSTR_CONNECT_RELAY: &str = "wss://relay.nsec.app";
|
|
||||||
|
|
||||||
/// Default retry count for fetching NIP-17 relays
|
|
||||||
pub const RELAY_RETRY: u64 = 2;
|
|
||||||
|
|
||||||
/// Default retry count for sending messages
|
|
||||||
pub const SEND_RETRY: u64 = 10;
|
|
||||||
|
|
||||||
/// Default timeout (in seconds) for Nostr Connect
|
|
||||||
pub const NOSTR_CONNECT_TIMEOUT: u64 = 200;
|
|
||||||
|
|
||||||
/// Default timeout (in seconds) for Nostr Connect (Bunker)
|
|
||||||
pub const BUNKER_TIMEOUT: u64 = 30;
|
|
||||||
|
|
||||||
/// Total metadata requests will be grouped.
|
|
||||||
pub const METADATA_BATCH_LIMIT: usize = 100;
|
|
||||||
|
|
||||||
/// Maximum timeout for grouping metadata requests. (milliseconds)
|
|
||||||
pub const METADATA_BATCH_TIMEOUT: u64 = 300;
|
|
||||||
|
|
||||||
/// Default width of the sidebar.
|
|
||||||
pub const DEFAULT_SIDEBAR_WIDTH: f32 = 240.;
|
|
||||||
|
|
||||||
/// Image Resize Service
|
|
||||||
pub const IMAGE_RESIZE_SERVICE: &str = "https://wsrv.nl";
|
|
||||||
|
|
||||||
/// Default NIP96 Media Server.
|
|
||||||
pub const NIP96_SERVER: &str = "https://nostrmedia.com";
|
|
||||||
@@ -1,260 +0,0 @@
|
|||||||
use std::collections::{HashMap, HashSet};
|
|
||||||
use std::sync::atomic::AtomicBool;
|
|
||||||
use std::sync::OnceLock;
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use flume::{Receiver, Sender};
|
|
||||||
use nostr_sdk::prelude::*;
|
|
||||||
use paths::nostr_file;
|
|
||||||
use smol::lock::RwLock;
|
|
||||||
|
|
||||||
use crate::paths::support_dir;
|
|
||||||
|
|
||||||
pub mod constants;
|
|
||||||
pub mod paths;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
|
||||||
pub struct AuthRequest {
|
|
||||||
pub url: RelayUrl,
|
|
||||||
pub challenge: String,
|
|
||||||
pub sending: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AuthRequest {
|
|
||||||
pub fn new(challenge: impl Into<String>, url: RelayUrl) -> Self {
|
|
||||||
Self {
|
|
||||||
challenge: challenge.into(),
|
|
||||||
sending: false,
|
|
||||||
url,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub enum Notice {
|
|
||||||
RelayFailed(RelayUrl),
|
|
||||||
AuthFailed(RelayUrl),
|
|
||||||
Custom(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Notice {
|
|
||||||
pub fn as_str(&self) -> String {
|
|
||||||
match self {
|
|
||||||
Notice::AuthFailed(url) => format!("Authenticate failed for relay {url}"),
|
|
||||||
Notice::RelayFailed(url) => format!("Failed to connect the relay {url}"),
|
|
||||||
Notice::Custom(msg) => msg.into(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, PartialEq, Eq, PartialOrd, Ord)]
|
|
||||||
pub enum UnwrappingStatus {
|
|
||||||
#[default]
|
|
||||||
Initialized,
|
|
||||||
Processing,
|
|
||||||
Complete,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Signals sent through the global event channel to notify UI
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub enum SignalKind {
|
|
||||||
/// A signal to notify UI that the client's signer has been set
|
|
||||||
SignerSet(PublicKey),
|
|
||||||
|
|
||||||
/// A signal to notify UI that the client's signer has been unset
|
|
||||||
SignerUnset,
|
|
||||||
|
|
||||||
/// A signal to notify UI that the relay requires authentication
|
|
||||||
Auth(AuthRequest),
|
|
||||||
|
|
||||||
/// A signal to notify UI that the browser proxy service is down
|
|
||||||
ProxyDown,
|
|
||||||
|
|
||||||
/// A signal to notify UI that a new profile has been received
|
|
||||||
NewProfile(Profile),
|
|
||||||
|
|
||||||
/// A signal to notify UI that a new gift wrap event has been received
|
|
||||||
NewMessage((EventId, Event)),
|
|
||||||
|
|
||||||
/// A signal to notify UI that no DM relays for current user was found
|
|
||||||
RelaysNotFound,
|
|
||||||
|
|
||||||
/// A signal to notify UI that gift wrap status has changed
|
|
||||||
GiftWrapStatus(UnwrappingStatus),
|
|
||||||
|
|
||||||
/// A signal to notify UI that there are errors or notices occurred
|
|
||||||
Notice(Notice),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct Signal {
|
|
||||||
rx: Receiver<SignalKind>,
|
|
||||||
tx: Sender<SignalKind>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for Signal {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Signal {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
let (tx, rx) = flume::bounded::<SignalKind>(2048);
|
|
||||||
Self { rx, tx }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn receiver(&self) -> &Receiver<SignalKind> {
|
|
||||||
&self.rx
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn send(&self, kind: SignalKind) {
|
|
||||||
if let Err(e) = self.tx.send_async(kind).await {
|
|
||||||
log::error!("Failed to send signal: {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct Ingester {
|
|
||||||
rx: Receiver<PublicKey>,
|
|
||||||
tx: Sender<PublicKey>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for Ingester {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Ingester {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
let (tx, rx) = flume::bounded::<PublicKey>(1024);
|
|
||||||
Self { rx, tx }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn receiver(&self) -> &Receiver<PublicKey> {
|
|
||||||
&self.rx
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn send(&self, public_key: PublicKey) {
|
|
||||||
if let Err(e) = self.tx.send_async(public_key).await {
|
|
||||||
log::error!("Failed to send public key: {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A simple storage to store all states that using across the application.
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct AppState {
|
|
||||||
/// The timestamp when the application was initialized.
|
|
||||||
pub init_at: Timestamp,
|
|
||||||
|
|
||||||
/// The timestamp when the application was last used.
|
|
||||||
pub last_used_at: Option<Timestamp>,
|
|
||||||
|
|
||||||
/// Whether this is the first run of the application.
|
|
||||||
pub is_first_run: AtomicBool,
|
|
||||||
|
|
||||||
/// Subscription ID for listening to gift wrap events from relays.
|
|
||||||
pub gift_wrap_sub_id: SubscriptionId,
|
|
||||||
|
|
||||||
/// Auto-close options for relay subscriptions
|
|
||||||
pub auto_close_opts: Option<SubscribeAutoCloseOptions>,
|
|
||||||
|
|
||||||
/// Whether gift wrap processing is in progress.
|
|
||||||
pub gift_wrap_processing: AtomicBool,
|
|
||||||
|
|
||||||
/// Tracking events sent by Coop in the current session
|
|
||||||
pub sent_ids: RwLock<HashSet<EventId>>,
|
|
||||||
|
|
||||||
/// Tracking events seen on which relays in the current session
|
|
||||||
pub seen_on_relays: RwLock<HashMap<EventId, HashSet<RelayUrl>>>,
|
|
||||||
|
|
||||||
/// Tracking events that have been resent by Coop in the current session
|
|
||||||
pub resent_ids: RwLock<Vec<Output<EventId>>>,
|
|
||||||
|
|
||||||
/// Temporarily store events that need to be resent later
|
|
||||||
pub resend_queue: RwLock<HashMap<EventId, RelayUrl>>,
|
|
||||||
|
|
||||||
/// Signal channel for communication between Nostr and GPUI
|
|
||||||
pub signal: Signal,
|
|
||||||
|
|
||||||
/// Ingester channel for processing public keys
|
|
||||||
pub ingester: Ingester,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for AppState {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AppState {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
let init_at = Timestamp::now();
|
|
||||||
let first_run = first_run();
|
|
||||||
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
|
|
||||||
|
|
||||||
let signal = Signal::default();
|
|
||||||
let ingester = Ingester::default();
|
|
||||||
|
|
||||||
Self {
|
|
||||||
init_at,
|
|
||||||
signal,
|
|
||||||
ingester,
|
|
||||||
last_used_at: None,
|
|
||||||
is_first_run: AtomicBool::new(first_run),
|
|
||||||
gift_wrap_sub_id: SubscriptionId::new("inbox"),
|
|
||||||
gift_wrap_processing: AtomicBool::new(false),
|
|
||||||
auto_close_opts: Some(opts),
|
|
||||||
sent_ids: RwLock::new(HashSet::new()),
|
|
||||||
seen_on_relays: RwLock::new(HashMap::new()),
|
|
||||||
resent_ids: RwLock::new(Vec::new()),
|
|
||||||
resend_queue: RwLock::new(HashMap::new()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static NOSTR_CLIENT: OnceLock<Client> = OnceLock::new();
|
|
||||||
static APP_STATE: OnceLock<AppState> = OnceLock::new();
|
|
||||||
|
|
||||||
pub fn nostr_client() -> &'static Client {
|
|
||||||
NOSTR_CLIENT.get_or_init(|| {
|
|
||||||
// rustls uses the `aws_lc_rs` provider by default
|
|
||||||
// This only errors if the default provider has already
|
|
||||||
// been installed. We can ignore this `Result`.
|
|
||||||
rustls::crypto::aws_lc_rs::default_provider()
|
|
||||||
.install_default()
|
|
||||||
.ok();
|
|
||||||
|
|
||||||
let lmdb = NostrLMDB::open(nostr_file()).expect("Database is NOT initialized");
|
|
||||||
|
|
||||||
let opts = ClientOptions::new()
|
|
||||||
.gossip(true)
|
|
||||||
.automatic_authentication(false)
|
|
||||||
.verify_subscriptions(false)
|
|
||||||
.sleep_when_idle(SleepWhenIdle::Enabled {
|
|
||||||
timeout: Duration::from_secs(600),
|
|
||||||
});
|
|
||||||
|
|
||||||
ClientBuilder::default().database(lmdb).opts(opts).build()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn app_state() -> &'static AppState {
|
|
||||||
APP_STATE.get_or_init(AppState::new)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn first_run() -> bool {
|
|
||||||
let flag = support_dir().join(format!(".{}-first_run", env!("CARGO_PKG_VERSION")));
|
|
||||||
|
|
||||||
if !flag.exists() {
|
|
||||||
if std::fs::write(&flag, "").is_err() {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
true // First run
|
|
||||||
} else {
|
|
||||||
false // Not first run
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
[package]
|
||||||
|
name = "key_store"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
common = { path = "../common" }
|
||||||
|
|
||||||
|
gpui.workspace = true
|
||||||
|
nostr-sdk.workspace = true
|
||||||
|
|
||||||
|
anyhow.workspace = true
|
||||||
|
smallvec.workspace = true
|
||||||
|
smol.workspace = true
|
||||||
|
log.workspace = true
|
||||||
|
futures.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
use std::any::Any;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::fmt::Display;
|
||||||
|
use std::future::Future;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::pin::Pin;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use common::config_dir;
|
||||||
|
use futures::FutureExt as _;
|
||||||
|
use gpui::AsyncApp;
|
||||||
|
use nostr_sdk::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct Credential {
|
||||||
|
public_key: PublicKey,
|
||||||
|
secret: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Credential {
|
||||||
|
pub fn new(user: String, secret: Vec<u8>) -> Self {
|
||||||
|
Self {
|
||||||
|
public_key: PublicKey::parse(&user).unwrap(),
|
||||||
|
secret: String::from_utf8(secret).unwrap(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn public_key(&self) -> PublicKey {
|
||||||
|
self.public_key
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn secret(&self) -> &str {
|
||||||
|
&self.secret
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub enum KeyItem {
|
||||||
|
User,
|
||||||
|
Bunker,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for KeyItem {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::User => write!(f, "coop-user"),
|
||||||
|
Self::Bunker => write!(f, "coop-bunker"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<KeyItem> for String {
|
||||||
|
fn from(item: KeyItem) -> Self {
|
||||||
|
item.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait KeyBackend: Any + Send + Sync {
|
||||||
|
fn name(&self) -> &str;
|
||||||
|
|
||||||
|
/// Reads the credentials from the provider.
|
||||||
|
#[allow(clippy::type_complexity)]
|
||||||
|
fn read_credentials<'a>(
|
||||||
|
&'a self,
|
||||||
|
url: &'a str,
|
||||||
|
cx: &'a AsyncApp,
|
||||||
|
) -> Pin<Box<dyn Future<Output = Result<Option<(String, Vec<u8>)>>> + 'a>>;
|
||||||
|
|
||||||
|
/// Writes the credentials to the provider.
|
||||||
|
fn write_credentials<'a>(
|
||||||
|
&'a self,
|
||||||
|
url: &'a str,
|
||||||
|
username: &'a str,
|
||||||
|
password: &'a [u8],
|
||||||
|
cx: &'a AsyncApp,
|
||||||
|
) -> Pin<Box<dyn Future<Output = Result<()>> + 'a>>;
|
||||||
|
|
||||||
|
/// Deletes the credentials from the provider.
|
||||||
|
fn delete_credentials<'a>(
|
||||||
|
&'a self,
|
||||||
|
url: &'a str,
|
||||||
|
cx: &'a AsyncApp,
|
||||||
|
) -> Pin<Box<dyn Future<Output = Result<()>> + 'a>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A credentials provider that stores credentials in the system keychain.
|
||||||
|
pub struct KeyringProvider;
|
||||||
|
|
||||||
|
impl KeyBackend for KeyringProvider {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"keyring"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_credentials<'a>(
|
||||||
|
&'a self,
|
||||||
|
url: &'a str,
|
||||||
|
cx: &'a AsyncApp,
|
||||||
|
) -> Pin<Box<dyn Future<Output = Result<Option<(String, Vec<u8>)>>> + 'a>> {
|
||||||
|
async move { cx.update(|cx| cx.read_credentials(url))?.await }.boxed_local()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_credentials<'a>(
|
||||||
|
&'a self,
|
||||||
|
url: &'a str,
|
||||||
|
username: &'a str,
|
||||||
|
password: &'a [u8],
|
||||||
|
cx: &'a AsyncApp,
|
||||||
|
) -> Pin<Box<dyn Future<Output = Result<()>> + 'a>> {
|
||||||
|
async move {
|
||||||
|
cx.update(move |cx| cx.write_credentials(url, username, password))?
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
.boxed_local()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete_credentials<'a>(
|
||||||
|
&'a self,
|
||||||
|
url: &'a str,
|
||||||
|
cx: &'a AsyncApp,
|
||||||
|
) -> Pin<Box<dyn Future<Output = Result<()>> + 'a>> {
|
||||||
|
async move { cx.update(move |cx| cx.delete_credentials(url))?.await }.boxed_local()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A credentials provider that stores credentials in a local file.
|
||||||
|
pub struct FileProvider {
|
||||||
|
path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FileProvider {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let path = config_dir().join(".keys");
|
||||||
|
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
let _ = std::fs::create_dir_all(parent);
|
||||||
|
}
|
||||||
|
|
||||||
|
Self { path }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_credentials(&self) -> Result<HashMap<String, (String, Vec<u8>)>> {
|
||||||
|
let json = std::fs::read(&self.path)?;
|
||||||
|
let credentials: HashMap<String, (String, Vec<u8>)> = serde_json::from_slice(&json)?;
|
||||||
|
|
||||||
|
Ok(credentials)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_credentials(&self, credentials: &HashMap<String, (String, Vec<u8>)>) -> Result<()> {
|
||||||
|
let json = serde_json::to_string(credentials)?;
|
||||||
|
std::fs::write(&self.path, json)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for FileProvider {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl KeyBackend for FileProvider {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"file"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_credentials<'a>(
|
||||||
|
&'a self,
|
||||||
|
url: &'a str,
|
||||||
|
_cx: &'a AsyncApp,
|
||||||
|
) -> Pin<Box<dyn Future<Output = Result<Option<(String, Vec<u8>)>>> + 'a>> {
|
||||||
|
async move {
|
||||||
|
Ok(self
|
||||||
|
.load_credentials()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.get(url)
|
||||||
|
.cloned())
|
||||||
|
}
|
||||||
|
.boxed_local()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_credentials<'a>(
|
||||||
|
&'a self,
|
||||||
|
url: &'a str,
|
||||||
|
username: &'a str,
|
||||||
|
password: &'a [u8],
|
||||||
|
_cx: &'a AsyncApp,
|
||||||
|
) -> Pin<Box<dyn Future<Output = Result<()>> + 'a>> {
|
||||||
|
async move {
|
||||||
|
let mut credentials = self.load_credentials().unwrap_or_default();
|
||||||
|
credentials.insert(url.to_string(), (username.to_string(), password.to_vec()));
|
||||||
|
|
||||||
|
self.save_credentials(&credentials)
|
||||||
|
}
|
||||||
|
.boxed_local()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete_credentials<'a>(
|
||||||
|
&'a self,
|
||||||
|
url: &'a str,
|
||||||
|
_cx: &'a AsyncApp,
|
||||||
|
) -> Pin<Box<dyn Future<Output = Result<()>> + 'a>> {
|
||||||
|
async move {
|
||||||
|
let mut credentials = self.load_credentials()?;
|
||||||
|
credentials.remove(url);
|
||||||
|
|
||||||
|
self.save_credentials(&credentials)
|
||||||
|
}
|
||||||
|
.boxed_local()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
use std::sync::{Arc, LazyLock};
|
||||||
|
|
||||||
|
pub use backend::*;
|
||||||
|
use gpui::{App, AppContext, Context, Entity, Global, Task};
|
||||||
|
use smallvec::{smallvec, SmallVec};
|
||||||
|
|
||||||
|
mod backend;
|
||||||
|
|
||||||
|
static DISABLE_KEYRING: LazyLock<bool> =
|
||||||
|
LazyLock::new(|| std::env::var("DISABLE_KEYRING").is_ok_and(|value| !value.is_empty()));
|
||||||
|
|
||||||
|
pub fn init(cx: &mut App) {
|
||||||
|
KeyStore::set_global(cx.new(KeyStore::new), cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
struct GlobalKeyStore(Entity<KeyStore>);
|
||||||
|
|
||||||
|
impl Global for GlobalKeyStore {}
|
||||||
|
|
||||||
|
pub struct KeyStore {
|
||||||
|
/// Key Store for storing credentials
|
||||||
|
pub backend: Arc<dyn KeyBackend>,
|
||||||
|
|
||||||
|
/// Whether the keystore has been initialized
|
||||||
|
pub initialized: bool,
|
||||||
|
|
||||||
|
/// Tasks for asynchronous operations
|
||||||
|
_tasks: SmallVec<[Task<()>; 1]>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl KeyStore {
|
||||||
|
/// Retrieve the global keys state
|
||||||
|
pub fn global(cx: &App) -> Entity<Self> {
|
||||||
|
cx.global::<GlobalKeyStore>().0.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the global keys instance
|
||||||
|
pub(crate) fn set_global(state: Entity<Self>, cx: &mut App) {
|
||||||
|
cx.set_global(GlobalKeyStore(state));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new keys instance
|
||||||
|
pub(crate) fn new(cx: &mut Context<Self>) -> Self {
|
||||||
|
// Use the file system for keystore in development or when the user specifies it
|
||||||
|
let use_file_keystore = cfg!(debug_assertions) || *DISABLE_KEYRING;
|
||||||
|
|
||||||
|
// Construct the key backend
|
||||||
|
let backend: Arc<dyn KeyBackend> = if use_file_keystore {
|
||||||
|
Arc::new(FileProvider::default())
|
||||||
|
} else {
|
||||||
|
Arc::new(KeyringProvider)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only used for testing keyring availability on the user's system
|
||||||
|
let read_credential = cx.read_credentials("Coop");
|
||||||
|
let mut tasks = smallvec![];
|
||||||
|
|
||||||
|
tasks.push(
|
||||||
|
// Verify the keyring availability
|
||||||
|
cx.spawn(async move |this, cx| {
|
||||||
|
let result = read_credential.await;
|
||||||
|
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
if let Err(e) = result {
|
||||||
|
log::error!("Keyring error: {e}");
|
||||||
|
// For Linux:
|
||||||
|
// The user has not installed secret service on their system
|
||||||
|
// Fall back to the file provider
|
||||||
|
this.backend = Arc::new(FileProvider::default());
|
||||||
|
}
|
||||||
|
this.initialized = true;
|
||||||
|
cx.notify();
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
backend,
|
||||||
|
initialized: false,
|
||||||
|
_tasks: tasks,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the key backend.
|
||||||
|
pub fn backend(&self) -> Arc<dyn KeyBackend> {
|
||||||
|
Arc::clone(&self.backend)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if the keystore is a file key backend.
|
||||||
|
pub fn is_using_file_keystore(&self) -> bool {
|
||||||
|
self.backend.name() == "file"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,17 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "client_keys"
|
name = "person"
|
||||||
version.workspace = true
|
version.workspace = true
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
publish.workspace = true
|
publish.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
global = { path = "../global" }
|
common = { path = "../common" }
|
||||||
|
state = { path = "../state" }
|
||||||
|
|
||||||
nostr-sdk.workspace = true
|
|
||||||
gpui.workspace = true
|
gpui.workspace = true
|
||||||
|
nostr-sdk.workspace = true
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
log.workspace = true
|
itertools.workspace = true
|
||||||
smallvec.workspace = true
|
smallvec.workspace = true
|
||||||
|
smol.workspace = true
|
||||||
|
log.workspace = true
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
|
use gpui::{App, AppContext, Context, Entity, Global, Task};
|
||||||
|
use nostr_sdk::prelude::*;
|
||||||
|
use smallvec::{smallvec, SmallVec};
|
||||||
|
use state::NostrRegistry;
|
||||||
|
|
||||||
|
pub fn init(cx: &mut App) {
|
||||||
|
PersonRegistry::set_global(cx.new(PersonRegistry::new), cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
struct GlobalPersonRegistry(Entity<PersonRegistry>);
|
||||||
|
|
||||||
|
impl Global for GlobalPersonRegistry {}
|
||||||
|
|
||||||
|
/// Person Registry
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct PersonRegistry {
|
||||||
|
/// Collection of all persons (user profiles)
|
||||||
|
pub persons: HashMap<PublicKey, Entity<Profile>>,
|
||||||
|
|
||||||
|
/// Tasks for asynchronous operations
|
||||||
|
_tasks: SmallVec<[Task<()>; 2]>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PersonRegistry {
|
||||||
|
/// Retrieve the global person registry state
|
||||||
|
pub fn global(cx: &App) -> Entity<Self> {
|
||||||
|
cx.global::<GlobalPersonRegistry>().0.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the global person registry instance
|
||||||
|
pub(crate) fn set_global(state: Entity<Self>, cx: &mut App) {
|
||||||
|
cx.set_global(GlobalPersonRegistry(state));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new person registry instance
|
||||||
|
pub(crate) fn new(cx: &mut Context<Self>) -> Self {
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let client = nostr.read(cx).client();
|
||||||
|
let mut tasks = smallvec![];
|
||||||
|
|
||||||
|
tasks.push(
|
||||||
|
// Handle notifications
|
||||||
|
cx.spawn({
|
||||||
|
let client = nostr.read(cx).client();
|
||||||
|
|
||||||
|
async move |this, cx| {
|
||||||
|
let mut notifications = client.notifications();
|
||||||
|
let mut processed_events = HashSet::new();
|
||||||
|
|
||||||
|
while let Ok(notification) = notifications.recv().await {
|
||||||
|
let RelayPoolNotification::Message { message, .. } = notification else {
|
||||||
|
// Skip if the notification is not a message
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
if let RelayMessage::Event { event, .. } = message {
|
||||||
|
if !processed_events.insert(event.id) {
|
||||||
|
// Skip if the event has already been processed
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if event.kind != Kind::Metadata {
|
||||||
|
// Skip if the event is not a metadata event
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let metadata = Metadata::from_json(&event.content).unwrap_or_default();
|
||||||
|
let profile = Profile::new(event.pubkey, metadata);
|
||||||
|
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.insert_or_update_person(profile, cx);
|
||||||
|
})
|
||||||
|
.expect("Entity has been released")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
tasks.push(
|
||||||
|
// Load all user profiles from the database
|
||||||
|
cx.spawn(async move |this, cx| {
|
||||||
|
let result = cx
|
||||||
|
.background_spawn(async move { Self::load_persons(&client).await })
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(profiles) => {
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.bulk_insert_persons(profiles, cx);
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Failed to load persons: {e}");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
persons: HashMap::new(),
|
||||||
|
_tasks: tasks,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load all user profiles from the database
|
||||||
|
async fn load_persons(client: &Client) -> Result<Vec<Profile>, Error> {
|
||||||
|
let filter = Filter::new().kind(Kind::Metadata).limit(200);
|
||||||
|
let events = client.database().query(filter).await?;
|
||||||
|
|
||||||
|
let mut profiles = vec![];
|
||||||
|
|
||||||
|
for event in events.into_iter() {
|
||||||
|
let metadata = Metadata::from_json(event.content).unwrap_or_default();
|
||||||
|
let profile = Profile::new(event.pubkey, metadata);
|
||||||
|
profiles.push(profile);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(profiles)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert batch of persons
|
||||||
|
fn bulk_insert_persons(&mut self, profiles: Vec<Profile>, cx: &mut Context<Self>) {
|
||||||
|
for profile in profiles.into_iter() {
|
||||||
|
self.persons
|
||||||
|
.insert(profile.public_key(), cx.new(|_| profile));
|
||||||
|
}
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert or update a person
|
||||||
|
pub fn insert_or_update_person(&mut self, profile: Profile, cx: &mut App) {
|
||||||
|
let public_key = profile.public_key();
|
||||||
|
|
||||||
|
match self.persons.get(&public_key) {
|
||||||
|
Some(person) => {
|
||||||
|
person.update(cx, |this, cx| {
|
||||||
|
*this = profile;
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
self.persons.insert(public_key, cx.new(|_| profile));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get single person
|
||||||
|
pub fn get_person(&self, public_key: &PublicKey, cx: &App) -> Profile {
|
||||||
|
self.persons
|
||||||
|
.get(public_key)
|
||||||
|
.map(|e| e.read(cx))
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or(Profile::new(public_key.to_owned(), Metadata::default()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get group of persons
|
||||||
|
pub fn get_group_person(&self, public_keys: &[PublicKey], cx: &App) -> Vec<Profile> {
|
||||||
|
let mut profiles = vec![];
|
||||||
|
|
||||||
|
for public_key in public_keys.iter() {
|
||||||
|
let profile = self.get_person(public_key, cx);
|
||||||
|
profiles.push(profile);
|
||||||
|
}
|
||||||
|
|
||||||
|
profiles
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,469 +0,0 @@
|
|||||||
use std::cmp::Reverse;
|
|
||||||
use std::collections::{HashMap, HashSet};
|
|
||||||
|
|
||||||
use anyhow::Error;
|
|
||||||
use common::event::EventUtils;
|
|
||||||
use fuzzy_matcher::skim::SkimMatcherV2;
|
|
||||||
use fuzzy_matcher::FuzzyMatcher;
|
|
||||||
use global::{nostr_client, UnwrappingStatus};
|
|
||||||
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task, WeakEntity, Window};
|
|
||||||
use itertools::Itertools;
|
|
||||||
use nostr_sdk::prelude::*;
|
|
||||||
use room::RoomKind;
|
|
||||||
use settings::AppSettings;
|
|
||||||
use smallvec::{smallvec, SmallVec};
|
|
||||||
|
|
||||||
use crate::room::Room;
|
|
||||||
|
|
||||||
pub mod message;
|
|
||||||
pub mod room;
|
|
||||||
|
|
||||||
pub fn init(cx: &mut App) {
|
|
||||||
Registry::set_global(cx.new(Registry::new), cx);
|
|
||||||
}
|
|
||||||
|
|
||||||
struct GlobalRegistry(Entity<Registry>);
|
|
||||||
|
|
||||||
impl Global for GlobalRegistry {}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub enum RegistryEvent {
|
|
||||||
Open(WeakEntity<Room>),
|
|
||||||
Close(u64),
|
|
||||||
NewRequest(RoomKind),
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Main registry for managing chat rooms and user profiles
|
|
||||||
pub struct Registry {
|
|
||||||
/// Collection of all chat rooms
|
|
||||||
pub rooms: Vec<Entity<Room>>,
|
|
||||||
|
|
||||||
/// Collection of all persons (user profiles)
|
|
||||||
pub persons: HashMap<PublicKey, Entity<Profile>>,
|
|
||||||
|
|
||||||
/// Status of the unwrapping process
|
|
||||||
pub unwrapping_status: Entity<UnwrappingStatus>,
|
|
||||||
|
|
||||||
/// Public key of the currently activated signer
|
|
||||||
signer_pubkey: Option<PublicKey>,
|
|
||||||
|
|
||||||
/// Tasks for asynchronous operations
|
|
||||||
_tasks: SmallVec<[Task<()>; 1]>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl EventEmitter<RegistryEvent> for Registry {}
|
|
||||||
|
|
||||||
impl Registry {
|
|
||||||
/// Retrieve the Global Registry state
|
|
||||||
pub fn global(cx: &App) -> Entity<Self> {
|
|
||||||
cx.global::<GlobalRegistry>().0.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Retrieve the Registry instance
|
|
||||||
pub fn read_global(cx: &App) -> &Self {
|
|
||||||
cx.global::<GlobalRegistry>().0.read(cx)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set the global Registry instance
|
|
||||||
pub(crate) fn set_global(state: Entity<Self>, cx: &mut App) {
|
|
||||||
cx.set_global(GlobalRegistry(state));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a new Registry instance
|
|
||||||
pub(crate) fn new(cx: &mut Context<Self>) -> Self {
|
|
||||||
let unwrapping_status = cx.new(|_| UnwrappingStatus::default());
|
|
||||||
let mut tasks = smallvec![];
|
|
||||||
|
|
||||||
let load_local_persons: Task<Result<Vec<Profile>, Error>> =
|
|
||||||
cx.background_spawn(async move {
|
|
||||||
let client = nostr_client();
|
|
||||||
let filter = Filter::new().kind(Kind::Metadata).limit(200);
|
|
||||||
let events = client.database().query(filter).await?;
|
|
||||||
let mut profiles = vec![];
|
|
||||||
|
|
||||||
for event in events.into_iter() {
|
|
||||||
let metadata = Metadata::from_json(event.content).unwrap_or_default();
|
|
||||||
let profile = Profile::new(event.pubkey, metadata);
|
|
||||||
profiles.push(profile);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(profiles)
|
|
||||||
});
|
|
||||||
|
|
||||||
tasks.push(
|
|
||||||
// Load all user profiles from the database when the Registry is created
|
|
||||||
cx.spawn(async move |this, cx| {
|
|
||||||
if let Ok(profiles) = load_local_persons.await {
|
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
this.set_persons(profiles, cx);
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
Self {
|
|
||||||
unwrapping_status,
|
|
||||||
rooms: vec![],
|
|
||||||
persons: HashMap::new(),
|
|
||||||
signer_pubkey: None,
|
|
||||||
_tasks: tasks,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the public key of the currently activated signer.
|
|
||||||
pub fn signer_pubkey(&self) -> Option<PublicKey> {
|
|
||||||
self.signer_pubkey
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update the public key of the currently activated signer.
|
|
||||||
pub fn set_signer_pubkey(&mut self, public_key: PublicKey, cx: &mut Context<Self>) {
|
|
||||||
self.signer_pubkey = Some(public_key);
|
|
||||||
cx.notify();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Insert batch of persons
|
|
||||||
pub fn set_persons(&mut self, profiles: Vec<Profile>, cx: &mut Context<Self>) {
|
|
||||||
for profile in profiles.into_iter() {
|
|
||||||
self.persons
|
|
||||||
.insert(profile.public_key(), cx.new(|_| profile));
|
|
||||||
}
|
|
||||||
cx.notify();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get single person
|
|
||||||
pub fn get_person(&self, public_key: &PublicKey, cx: &App) -> Profile {
|
|
||||||
self.persons
|
|
||||||
.get(public_key)
|
|
||||||
.map(|e| e.read(cx))
|
|
||||||
.cloned()
|
|
||||||
.unwrap_or(Profile::new(public_key.to_owned(), Metadata::default()))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get group of persons
|
|
||||||
pub fn get_group_person(&self, public_keys: &[PublicKey], cx: &App) -> Vec<Profile> {
|
|
||||||
let mut profiles = vec![];
|
|
||||||
|
|
||||||
for public_key in public_keys.iter() {
|
|
||||||
let profile = self.get_person(public_key, cx);
|
|
||||||
profiles.push(profile);
|
|
||||||
}
|
|
||||||
|
|
||||||
profiles
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Insert or update a person
|
|
||||||
pub fn insert_or_update_person(&mut self, profile: Profile, cx: &mut App) {
|
|
||||||
let public_key = profile.public_key();
|
|
||||||
|
|
||||||
match self.persons.get(&public_key) {
|
|
||||||
Some(person) => {
|
|
||||||
person.update(cx, |this, cx| {
|
|
||||||
*this = profile;
|
|
||||||
cx.notify();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
self.persons.insert(public_key, cx.new(|_| profile));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get a room by its ID.
|
|
||||||
pub fn room(&self, id: &u64, cx: &App) -> Option<Entity<Room>> {
|
|
||||||
self.rooms
|
|
||||||
.iter()
|
|
||||||
.find(|model| model.read(cx).id == *id)
|
|
||||||
.cloned()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get all ongoing rooms.
|
|
||||||
pub fn ongoing_rooms(&self, cx: &App) -> Vec<Entity<Room>> {
|
|
||||||
self.rooms
|
|
||||||
.iter()
|
|
||||||
.filter(|room| room.read(cx).kind == RoomKind::Ongoing)
|
|
||||||
.cloned()
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get all request rooms.
|
|
||||||
pub fn request_rooms(&self, cx: &App) -> Vec<Entity<Room>> {
|
|
||||||
self.rooms
|
|
||||||
.iter()
|
|
||||||
.filter(|room| room.read(cx).kind != RoomKind::Ongoing)
|
|
||||||
.cloned()
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Add a new room to the start of list.
|
|
||||||
pub fn add_room(&mut self, room: Entity<Room>, cx: &mut Context<Self>) {
|
|
||||||
self.rooms.insert(0, room);
|
|
||||||
cx.notify();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Close a room.
|
|
||||||
pub fn close_room(&mut self, id: u64, cx: &mut Context<Self>) {
|
|
||||||
if self.rooms.iter().any(|r| r.read(cx).id == id) {
|
|
||||||
cx.emit(RegistryEvent::Close(id));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sort rooms by their created at.
|
|
||||||
pub fn sort(&mut self, cx: &mut Context<Self>) {
|
|
||||||
self.rooms.sort_by_key(|ev| Reverse(ev.read(cx).created_at));
|
|
||||||
cx.notify();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Search rooms by their name.
|
|
||||||
pub fn search(&self, query: &str, cx: &App) -> Vec<Entity<Room>> {
|
|
||||||
let matcher = SkimMatcherV2::default();
|
|
||||||
|
|
||||||
self.rooms
|
|
||||||
.iter()
|
|
||||||
.filter(|room| {
|
|
||||||
matcher
|
|
||||||
.fuzzy_match(room.read(cx).display_name(cx).as_ref(), query)
|
|
||||||
.is_some()
|
|
||||||
})
|
|
||||||
.cloned()
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Search rooms by public keys.
|
|
||||||
pub fn search_by_public_key(&self, public_key: PublicKey, cx: &App) -> Vec<Entity<Room>> {
|
|
||||||
self.rooms
|
|
||||||
.iter()
|
|
||||||
.filter(|room| room.read(cx).members.contains(&public_key))
|
|
||||||
.cloned()
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set the loading status of the registry.
|
|
||||||
pub fn set_unwrapping_status(&mut self, status: UnwrappingStatus, cx: &mut Context<Self>) {
|
|
||||||
self.unwrapping_status.update(cx, |this, cx| {
|
|
||||||
*this = status;
|
|
||||||
cx.notify();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reset the registry.
|
|
||||||
pub fn reset(&mut self, cx: &mut Context<Self>) {
|
|
||||||
// Reset the unwrapping status
|
|
||||||
self.set_unwrapping_status(UnwrappingStatus::default(), cx);
|
|
||||||
|
|
||||||
// Clear the current identity
|
|
||||||
self.signer_pubkey = None;
|
|
||||||
|
|
||||||
// Clear all current rooms
|
|
||||||
self.rooms.clear();
|
|
||||||
|
|
||||||
cx.notify();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Load all rooms from the database.
|
|
||||||
pub fn load_rooms(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
||||||
log::info!("Starting to load chat rooms...");
|
|
||||||
|
|
||||||
// Get the contact bypass setting
|
|
||||||
let bypass_setting = AppSettings::get_contact_bypass(cx);
|
|
||||||
|
|
||||||
let task: Task<Result<HashSet<Room>, Error>> = cx.background_spawn(async move {
|
|
||||||
let client = nostr_client();
|
|
||||||
let signer = client.signer().await?;
|
|
||||||
let public_key = signer.get_public_key().await?;
|
|
||||||
let contacts = client.database().contacts_public_keys(public_key).await?;
|
|
||||||
|
|
||||||
// Get messages sent by the user
|
|
||||||
let sent = Filter::new()
|
|
||||||
.kind(Kind::PrivateDirectMessage)
|
|
||||||
.author(public_key);
|
|
||||||
|
|
||||||
// Get messages received by the user
|
|
||||||
let recv = Filter::new()
|
|
||||||
.kind(Kind::PrivateDirectMessage)
|
|
||||||
.pubkey(public_key);
|
|
||||||
|
|
||||||
let sent_events = client.database().query(sent).await?;
|
|
||||||
let recv_events = client.database().query(recv).await?;
|
|
||||||
let events = sent_events.merge(recv_events);
|
|
||||||
|
|
||||||
let mut rooms: HashSet<Room> = HashSet::new();
|
|
||||||
|
|
||||||
// Process each event and group by room hash
|
|
||||||
for event in events
|
|
||||||
.into_iter()
|
|
||||||
.sorted_by_key(|event| Reverse(event.created_at))
|
|
||||||
.filter(|ev| ev.tags.public_keys().peekable().peek().is_some())
|
|
||||||
{
|
|
||||||
// Parse the room from the nostr event
|
|
||||||
let room = Room::from(&event);
|
|
||||||
|
|
||||||
// Skip if the room is already in the set
|
|
||||||
if rooms.iter().any(|r| r.id == room.id) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get all public keys from the event's tags
|
|
||||||
let mut public_keys: Vec<PublicKey> = room.members().to_vec();
|
|
||||||
public_keys.retain(|pk| pk != &public_key);
|
|
||||||
|
|
||||||
// Bypass screening flag
|
|
||||||
let mut bypassed = false;
|
|
||||||
|
|
||||||
// If the user has enabled bypass screening in settings,
|
|
||||||
// check if any of the room's members are contacts of the current user
|
|
||||||
if bypass_setting {
|
|
||||||
bypassed = public_keys.iter().any(|k| contacts.contains(k));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the current user has sent at least one message to this room
|
|
||||||
let filter = Filter::new()
|
|
||||||
.kind(Kind::PrivateDirectMessage)
|
|
||||||
.author(public_key)
|
|
||||||
.pubkeys(public_keys);
|
|
||||||
|
|
||||||
// If current user has sent a message at least once, mark as ongoing
|
|
||||||
let is_ongoing = client.database().count(filter).await.unwrap_or(1) >= 1;
|
|
||||||
|
|
||||||
if is_ongoing || bypassed {
|
|
||||||
rooms.insert(room.kind(RoomKind::Ongoing));
|
|
||||||
} else {
|
|
||||||
rooms.insert(room);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(rooms)
|
|
||||||
});
|
|
||||||
|
|
||||||
cx.spawn_in(window, async move |this, cx| {
|
|
||||||
match task.await {
|
|
||||||
Ok(rooms) => {
|
|
||||||
this.update_in(cx, move |_, window, cx| {
|
|
||||||
cx.defer_in(window, move |this, _window, cx| {
|
|
||||||
this.extend_rooms(rooms, cx);
|
|
||||||
this.sort(cx);
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
log::error!("Failed to load rooms: {e}")
|
|
||||||
}
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.detach();
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn extend_rooms(&mut self, rooms: HashSet<Room>, cx: &mut Context<Self>) {
|
|
||||||
let mut room_map: HashMap<u64, usize> = self
|
|
||||||
.rooms
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.map(|(idx, room)| (room.read(cx).id, idx))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
for new_room in rooms.into_iter() {
|
|
||||||
// Check if we already have a room with this ID
|
|
||||||
if let Some(&index) = room_map.get(&new_room.id) {
|
|
||||||
self.rooms[index].update(cx, |this, cx| {
|
|
||||||
if new_room.created_at > this.created_at {
|
|
||||||
*this = new_room;
|
|
||||||
cx.notify();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
let new_room_id = new_room.id;
|
|
||||||
self.rooms.push(cx.new(|_| new_room));
|
|
||||||
|
|
||||||
let new_index = self.rooms.len();
|
|
||||||
room_map.insert(new_room_id, new_index);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Push a new Room to the global registry
|
|
||||||
pub fn push_room(&mut self, room: Entity<Room>, cx: &mut Context<Self>) {
|
|
||||||
let other_id = room.read(cx).id;
|
|
||||||
let find_room = self.rooms.iter().find(|this| this.read(cx).id == other_id);
|
|
||||||
|
|
||||||
let weak_room = if let Some(room) = find_room {
|
|
||||||
room.downgrade()
|
|
||||||
} else {
|
|
||||||
let weak_room = room.downgrade();
|
|
||||||
// Add this room to the registry
|
|
||||||
self.add_room(room, cx);
|
|
||||||
|
|
||||||
weak_room
|
|
||||||
};
|
|
||||||
|
|
||||||
cx.emit(RegistryEvent::Open(weak_room));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Refresh messages for a room in the global registry
|
|
||||||
pub fn refresh_rooms(&mut self, ids: Option<Vec<u64>>, cx: &mut Context<Self>) {
|
|
||||||
if let Some(ids) = ids {
|
|
||||||
for room in self.rooms.iter() {
|
|
||||||
if ids.contains(&room.read(cx).id) {
|
|
||||||
room.update(cx, |this, cx| {
|
|
||||||
this.emit_refresh(cx);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse a Nostr event into a Coop Message and push it to the belonging room
|
|
||||||
///
|
|
||||||
/// If the room doesn't exist, it will be created.
|
|
||||||
/// Updates room ordering based on the most recent messages.
|
|
||||||
pub fn event_to_message(
|
|
||||||
&mut self,
|
|
||||||
gift_wrap: EventId,
|
|
||||||
event: Event,
|
|
||||||
window: &mut Window,
|
|
||||||
cx: &mut Context<Self>,
|
|
||||||
) {
|
|
||||||
let id = event.uniq_id();
|
|
||||||
let author = event.pubkey;
|
|
||||||
|
|
||||||
let Some(public_key) = self.signer_pubkey else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Some(room) = self.rooms.iter().find(|room| room.read(cx).id == id) {
|
|
||||||
let is_new_event = event.created_at > room.read(cx).created_at;
|
|
||||||
|
|
||||||
// Update room
|
|
||||||
room.update(cx, |this, cx| {
|
|
||||||
if is_new_event {
|
|
||||||
this.set_created_at(event.created_at, cx);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set this room is ongoing if the new message is from current user
|
|
||||||
if author == public_key {
|
|
||||||
this.set_ongoing(cx);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Emit the new message to the room
|
|
||||||
cx.defer_in(window, move |this, _window, cx| {
|
|
||||||
this.emit_message(gift_wrap, event, cx);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Resort all rooms in the registry by their created at (after updated)
|
|
||||||
if is_new_event {
|
|
||||||
cx.defer_in(window, |this, _window, cx| {
|
|
||||||
this.sort(cx);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Push the new room to the front of the list
|
|
||||||
self.add_room(cx.new(|_| Room::from(&event)), cx);
|
|
||||||
|
|
||||||
// Notify the UI about the new room
|
|
||||||
cx.defer_in(window, move |_this, _window, cx| {
|
|
||||||
cx.emit(RegistryEvent::NewRequest(RoomKind::default()));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +1,20 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "registry"
|
name = "relay_auth"
|
||||||
version.workspace = true
|
version.workspace = true
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
publish.workspace = true
|
publish.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
common = { path = "../common" }
|
state = { path = "../state" }
|
||||||
global = { path = "../global" }
|
|
||||||
settings = { path = "../settings" }
|
settings = { path = "../settings" }
|
||||||
|
common = { path = "../common" }
|
||||||
|
theme = { path = "../theme" }
|
||||||
|
ui = { path = "../ui" }
|
||||||
|
|
||||||
gpui.workspace = true
|
gpui.workspace = true
|
||||||
nostr.workspace = true
|
|
||||||
nostr-sdk.workspace = true
|
nostr-sdk.workspace = true
|
||||||
|
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
itertools.workspace = true
|
|
||||||
smallvec.workspace = true
|
smallvec.workspace = true
|
||||||
smol.workspace = true
|
smol.workspace = true
|
||||||
log.workspace = true
|
log.workspace = true
|
||||||
|
|
||||||
fuzzy-matcher = "0.3.7"
|
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
use std::borrow::Cow;
|
||||||
|
use std::cell::Cell;
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::hash::{Hash, Hasher};
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use anyhow::{anyhow, Error};
|
||||||
|
use gpui::{
|
||||||
|
App, AppContext, Context, Entity, Global, IntoElement, ParentElement, SharedString, Styled,
|
||||||
|
Subscription, Task, Window,
|
||||||
|
};
|
||||||
|
use nostr_sdk::prelude::*;
|
||||||
|
use settings::AppSettings;
|
||||||
|
use smallvec::{smallvec, SmallVec};
|
||||||
|
use state::NostrRegistry;
|
||||||
|
use theme::ActiveTheme;
|
||||||
|
use ui::button::{Button, ButtonVariants};
|
||||||
|
use ui::notification::Notification;
|
||||||
|
use ui::{v_flex, ContextModal, Disableable, IconName, Sizable};
|
||||||
|
|
||||||
|
const AUTH_MESSAGE: &str =
|
||||||
|
"Approve the authentication request to allow Coop to continue sending or receiving events.";
|
||||||
|
|
||||||
|
pub fn init(window: &mut Window, cx: &mut App) {
|
||||||
|
RelayAuth::set_global(cx.new(|cx| RelayAuth::new(window, cx)), cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
struct GlobalRelayAuth(Entity<RelayAuth>);
|
||||||
|
|
||||||
|
impl Global for GlobalRelayAuth {}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
|
pub struct AuthRequest {
|
||||||
|
pub url: RelayUrl,
|
||||||
|
pub challenge: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Hash for AuthRequest {
|
||||||
|
fn hash<H: Hasher>(&self, state: &mut H) {
|
||||||
|
self.challenge.hash(state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuthRequest {
|
||||||
|
pub fn new(challenge: impl Into<String>, url: RelayUrl) -> Self {
|
||||||
|
Self {
|
||||||
|
challenge: challenge.into(),
|
||||||
|
url,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct RelayAuth {
|
||||||
|
/// Entity for managing auth requests
|
||||||
|
requests: HashSet<AuthRequest>,
|
||||||
|
|
||||||
|
/// Event subscriptions
|
||||||
|
_subscriptions: SmallVec<[Subscription; 1]>,
|
||||||
|
|
||||||
|
/// Tasks for asynchronous operations
|
||||||
|
_tasks: SmallVec<[Task<()>; 1]>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RelayAuth {
|
||||||
|
/// Retrieve the global relay auth state
|
||||||
|
pub fn global(cx: &App) -> Entity<Self> {
|
||||||
|
cx.global::<GlobalRelayAuth>().0.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the global relay auth instance
|
||||||
|
fn set_global(state: Entity<Self>, cx: &mut App) {
|
||||||
|
cx.set_global(GlobalRelayAuth(state));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new relay auth instance
|
||||||
|
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let client = nostr.read(cx).client();
|
||||||
|
let entity = cx.entity();
|
||||||
|
|
||||||
|
let mut subscriptions = smallvec![];
|
||||||
|
let mut tasks = smallvec![];
|
||||||
|
|
||||||
|
subscriptions.push(
|
||||||
|
// Observe the current state
|
||||||
|
cx.observe_in(&entity, window, |this, _, window, cx| {
|
||||||
|
let settings = AppSettings::global(cx);
|
||||||
|
let auto_auth = AppSettings::get_auto_auth(cx);
|
||||||
|
|
||||||
|
for req in this.requests.clone().into_iter() {
|
||||||
|
let is_authenticated = settings.read(cx).is_authenticated(&req.url);
|
||||||
|
|
||||||
|
if auto_auth && is_authenticated {
|
||||||
|
// Automatically authenticate if the relay is authenticated before
|
||||||
|
this.response(req.to_owned(), window, cx);
|
||||||
|
} else {
|
||||||
|
// Otherwise open the auth request popup
|
||||||
|
this.ask_for_approval(req.to_owned(), window, cx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
tasks.push(
|
||||||
|
// Handle notifications
|
||||||
|
cx.spawn(async move |this, cx| {
|
||||||
|
let mut notifications = client.notifications();
|
||||||
|
let mut challenges: HashSet<Cow<'_, str>> = HashSet::new();
|
||||||
|
|
||||||
|
while let Ok(notification) = notifications.recv().await {
|
||||||
|
let RelayPoolNotification::Message { message, relay_url } = notification else {
|
||||||
|
// Skip if the notification is not a message
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
if let RelayMessage::Auth { challenge } = message {
|
||||||
|
if challenges.insert(challenge.clone()) {
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.requests.insert(AuthRequest::new(challenge, relay_url));
|
||||||
|
cx.notify();
|
||||||
|
})
|
||||||
|
.expect("Entity has been released");
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
requests: HashSet::new(),
|
||||||
|
_subscriptions: subscriptions,
|
||||||
|
_tasks: tasks,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the number of pending requests.
|
||||||
|
pub fn pending_requests(&self, _cx: &App) -> usize {
|
||||||
|
self.requests.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reask for approval for all pending requests.
|
||||||
|
pub fn re_ask(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
for request in self.requests.clone().into_iter() {
|
||||||
|
self.ask_for_approval(request, window, cx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Respond to an authentication request.
|
||||||
|
fn response(&mut self, req: AuthRequest, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
let settings = AppSettings::global(cx);
|
||||||
|
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let client = nostr.read(cx).client();
|
||||||
|
let tracker = nostr.read(cx).tracker();
|
||||||
|
|
||||||
|
let challenge = req.challenge.to_owned();
|
||||||
|
let url = req.url.to_owned();
|
||||||
|
|
||||||
|
let challenge_clone = challenge.clone();
|
||||||
|
let url_clone = url.clone();
|
||||||
|
|
||||||
|
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||||
|
let signer = client.signer().await?;
|
||||||
|
|
||||||
|
// Construct event
|
||||||
|
let event: Event = EventBuilder::auth(challenge_clone, url_clone.clone())
|
||||||
|
.sign(&signer)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Get the event ID
|
||||||
|
let id = event.id;
|
||||||
|
|
||||||
|
// Get the relay
|
||||||
|
let relay = client.pool().relay(url_clone).await?;
|
||||||
|
let relay_url = relay.url();
|
||||||
|
|
||||||
|
// Subscribe to notifications
|
||||||
|
let mut notifications = relay.notifications();
|
||||||
|
|
||||||
|
// Send the AUTH message
|
||||||
|
relay.send_msg(ClientMessage::Auth(Cow::Borrowed(&event)))?;
|
||||||
|
|
||||||
|
while let Ok(notification) = notifications.recv().await {
|
||||||
|
match notification {
|
||||||
|
RelayNotification::Message {
|
||||||
|
message: RelayMessage::Ok { event_id, .. },
|
||||||
|
} => {
|
||||||
|
if id == event_id {
|
||||||
|
// Re-subscribe to previous subscription
|
||||||
|
relay.resubscribe().await?;
|
||||||
|
|
||||||
|
// Get all failed events that need to be resent
|
||||||
|
let mut tracker = tracker.write().await;
|
||||||
|
|
||||||
|
let ids: Vec<EventId> = tracker
|
||||||
|
.resend_queue
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, url)| relay_url == *url)
|
||||||
|
.map(|(id, _)| *id)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for id in ids.into_iter() {
|
||||||
|
if let Some(relay_url) = tracker.resend_queue.remove(&id) {
|
||||||
|
if let Some(event) = client.database().event_by_id(&id).await? {
|
||||||
|
let event_id = relay.send_event(&event).await?;
|
||||||
|
|
||||||
|
let output = Output {
|
||||||
|
val: event_id,
|
||||||
|
failed: HashMap::new(),
|
||||||
|
success: HashSet::from([relay_url]),
|
||||||
|
};
|
||||||
|
|
||||||
|
tracker.sent_ids.insert(event_id);
|
||||||
|
tracker.resent_ids.push(output);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RelayNotification::AuthenticationFailed => break,
|
||||||
|
RelayNotification::Shutdown => break,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(anyhow!("Authentication failed"))
|
||||||
|
});
|
||||||
|
|
||||||
|
self._tasks.push(
|
||||||
|
// Handle response in the background
|
||||||
|
cx.spawn_in(window, async move |this, cx| {
|
||||||
|
match task.await {
|
||||||
|
Ok(_) => {
|
||||||
|
this.update_in(cx, |this, window, cx| {
|
||||||
|
// Clear the current notification
|
||||||
|
window.clear_notification_by_id(SharedString::from(&challenge), cx);
|
||||||
|
|
||||||
|
// Push a new notification
|
||||||
|
window.push_notification(format!("{url} has been authenticated"), cx);
|
||||||
|
|
||||||
|
// Save the authenticated relay to automatically authenticate future requests
|
||||||
|
settings.update(cx, |this, cx| {
|
||||||
|
this.push_relay(&url, cx);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove the challenge from the list of pending authentications
|
||||||
|
this.requests.remove(&req);
|
||||||
|
cx.notify();
|
||||||
|
})
|
||||||
|
.expect("Entity has been released");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
this.update_in(cx, |_, window, cx| {
|
||||||
|
window.push_notification(Notification::error(e.to_string()), cx);
|
||||||
|
})
|
||||||
|
.expect("Entity has been released");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Push a popup to approve the authentication request.
|
||||||
|
fn ask_for_approval(&mut self, req: AuthRequest, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
let url = SharedString::from(req.url.clone().to_string());
|
||||||
|
let entity = cx.entity().downgrade();
|
||||||
|
let loading = Rc::new(Cell::new(false));
|
||||||
|
|
||||||
|
let note = Notification::new()
|
||||||
|
.custom_id(SharedString::from(&req.challenge))
|
||||||
|
.autohide(false)
|
||||||
|
.icon(IconName::Info)
|
||||||
|
.title(SharedString::from("Authentication Required"))
|
||||||
|
.content(move |_window, cx| {
|
||||||
|
v_flex()
|
||||||
|
.gap_2()
|
||||||
|
.text_sm()
|
||||||
|
.child(SharedString::from(AUTH_MESSAGE))
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.py_1()
|
||||||
|
.px_1p5()
|
||||||
|
.rounded_sm()
|
||||||
|
.text_xs()
|
||||||
|
.bg(cx.theme().warning_background)
|
||||||
|
.text_color(cx.theme().warning_foreground)
|
||||||
|
.child(url.clone()),
|
||||||
|
)
|
||||||
|
.into_any_element()
|
||||||
|
})
|
||||||
|
.action(move |_window, _cx| {
|
||||||
|
let entity = entity.clone();
|
||||||
|
let req = req.clone();
|
||||||
|
|
||||||
|
Button::new("approve")
|
||||||
|
.label("Approve")
|
||||||
|
.small()
|
||||||
|
.primary()
|
||||||
|
.loading(loading.get())
|
||||||
|
.disabled(loading.get())
|
||||||
|
.on_click({
|
||||||
|
let loading = Rc::clone(&loading);
|
||||||
|
move |_ev, window, cx| {
|
||||||
|
// Set loading state to true
|
||||||
|
loading.set(true);
|
||||||
|
// Process to approve the request
|
||||||
|
entity
|
||||||
|
.update(cx, |this, cx| {
|
||||||
|
this.response(req.clone(), window, cx);
|
||||||
|
})
|
||||||
|
.expect("Entity has been released");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
// Push the notification to the current window
|
||||||
|
window.push_notification(note, cx);
|
||||||
|
|
||||||
|
// Focus the window if it's not active
|
||||||
|
if !window.is_window_hovered() {
|
||||||
|
window.activate_window();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ edition.workspace = true
|
|||||||
publish.workspace = true
|
publish.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
global = { path = "../global" }
|
state = { path = "../state" }
|
||||||
|
|
||||||
nostr-sdk.workspace = true
|
nostr-sdk.workspace = true
|
||||||
gpui.workspace = true
|
gpui.workspace = true
|
||||||
|
|||||||
+78
-46
@@ -1,23 +1,14 @@
|
|||||||
use anyhow::anyhow;
|
use anyhow::{anyhow, Error};
|
||||||
use global::constants::SETTINGS_IDENTIFIER;
|
|
||||||
use global::nostr_client;
|
|
||||||
use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task};
|
use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use smallvec::{smallvec, SmallVec};
|
use smallvec::{smallvec, SmallVec};
|
||||||
|
use state::NostrRegistry;
|
||||||
|
|
||||||
|
const SETTINGS_IDENTIFIER: &str = "coop:settings";
|
||||||
|
|
||||||
pub fn init(cx: &mut App) {
|
pub fn init(cx: &mut App) {
|
||||||
let state = cx.new(AppSettings::new);
|
AppSettings::set_global(cx.new(AppSettings::new), cx)
|
||||||
|
|
||||||
// Observe for state changes and save settings to database
|
|
||||||
state.update(cx, |this, cx| {
|
|
||||||
this._subscriptions
|
|
||||||
.push(cx.observe(&state, |this, _state, cx| {
|
|
||||||
this.set_settings(cx);
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
|
|
||||||
AppSettings::set_global(state, cx);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
macro_rules! setting_accessors {
|
macro_rules! setting_accessors {
|
||||||
@@ -26,7 +17,7 @@ macro_rules! setting_accessors {
|
|||||||
$(
|
$(
|
||||||
paste::paste! {
|
paste::paste! {
|
||||||
pub fn [<get_ $field>](cx: &App) -> $type {
|
pub fn [<get_ $field>](cx: &App) -> $type {
|
||||||
Self::read_global(cx).setting_values.$field.clone()
|
Self::global(cx).read(cx).setting_values.$field.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn [<update_ $field>](value: $type, cx: &mut App) {
|
pub fn [<update_ $field>](value: $type, cx: &mut App) {
|
||||||
@@ -52,7 +43,7 @@ setting_accessors! {
|
|||||||
pub auto_auth: bool,
|
pub auto_auth: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Settings {
|
pub struct Settings {
|
||||||
pub media_server: Url,
|
pub media_server: Url,
|
||||||
pub proxy_user_avatars: bool,
|
pub proxy_user_avatars: bool,
|
||||||
@@ -93,7 +84,12 @@ impl Global for GlobalAppSettings {}
|
|||||||
|
|
||||||
pub struct AppSettings {
|
pub struct AppSettings {
|
||||||
setting_values: Settings,
|
setting_values: Settings,
|
||||||
|
|
||||||
|
// Event subscriptions
|
||||||
_subscriptions: SmallVec<[Subscription; 1]>,
|
_subscriptions: SmallVec<[Subscription; 1]>,
|
||||||
|
|
||||||
|
// Background tasks
|
||||||
|
_tasks: SmallVec<[Task<()>; 1]>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppSettings {
|
impl AppSettings {
|
||||||
@@ -102,42 +98,49 @@ impl AppSettings {
|
|||||||
cx.global::<GlobalAppSettings>().0.clone()
|
cx.global::<GlobalAppSettings>().0.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Retrieve the Settings instance
|
|
||||||
pub fn read_global(cx: &App) -> &Self {
|
|
||||||
cx.global::<GlobalAppSettings>().0.read(cx)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set the Global Settings instance
|
/// Set the Global Settings instance
|
||||||
pub(crate) fn set_global(state: Entity<Self>, cx: &mut App) {
|
pub(crate) fn set_global(state: Entity<Self>, cx: &mut App) {
|
||||||
cx.set_global(GlobalAppSettings(state));
|
cx.set_global(GlobalAppSettings(state));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn new(_cx: &mut Context<Self>) -> Self {
|
fn new(cx: &mut Context<Self>) -> Self {
|
||||||
|
let load_settings = Self::_load_settings(false, cx);
|
||||||
|
|
||||||
|
let mut tasks = smallvec![];
|
||||||
|
let mut subscriptions = smallvec![];
|
||||||
|
|
||||||
|
subscriptions.push(
|
||||||
|
// Observe and automatically save settings on changes
|
||||||
|
cx.observe_self(|this, cx| {
|
||||||
|
this.set_settings(cx);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
tasks.push(
|
||||||
|
// Load the initial settings
|
||||||
|
cx.spawn(async move |this, cx| {
|
||||||
|
if let Ok(settings) = load_settings.await {
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.setting_values = settings;
|
||||||
|
cx.notify();
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
setting_values: Settings::default(),
|
setting_values: Settings::default(),
|
||||||
_subscriptions: smallvec![],
|
_subscriptions: subscriptions,
|
||||||
|
_tasks: tasks,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn load_settings(&self, cx: &mut Context<Self>) {
|
pub fn load_settings(&mut self, cx: &mut Context<Self>) {
|
||||||
let task: Task<Result<Settings, anyhow::Error>> = cx.background_spawn(async move {
|
let task = Self::_load_settings(true, cx);
|
||||||
let client = nostr_client();
|
|
||||||
let signer = client.signer().await?;
|
|
||||||
let public_key = signer.get_public_key().await?;
|
|
||||||
|
|
||||||
let filter = Filter::new()
|
|
||||||
.kind(Kind::ApplicationSpecificData)
|
|
||||||
.identifier(SETTINGS_IDENTIFIER)
|
|
||||||
.author(public_key)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if let Some(event) = client.database().query(filter).await?.first_owned() {
|
|
||||||
Ok(serde_json::from_str(&event.content).unwrap_or(Settings::default()))
|
|
||||||
} else {
|
|
||||||
Err(anyhow!("Not found"))
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
|
self._tasks.push(
|
||||||
|
// Run task in the background
|
||||||
cx.spawn(async move |this, cx| {
|
cx.spawn(async move |this, cx| {
|
||||||
if let Ok(settings) = task.await {
|
if let Ok(settings) = task.await {
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
@@ -146,14 +149,40 @@ impl AppSettings {
|
|||||||
})
|
})
|
||||||
.ok();
|
.ok();
|
||||||
}
|
}
|
||||||
})
|
}),
|
||||||
.detach();
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_settings(&self, cx: &mut Context<Self>) {
|
fn _load_settings(user: bool, cx: &App) -> Task<Result<Settings, Error>> {
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let client = nostr.read(cx).client();
|
||||||
|
|
||||||
|
cx.background_spawn(async move {
|
||||||
|
let mut filter = Filter::new()
|
||||||
|
.kind(Kind::ApplicationSpecificData)
|
||||||
|
.identifier(SETTINGS_IDENTIFIER)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if user {
|
||||||
|
let signer = client.signer().await?;
|
||||||
|
let public_key = signer.get_public_key().await?;
|
||||||
|
filter = filter.author(public_key);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(event) = client.database().query(filter).await?.first_owned() {
|
||||||
|
Ok(serde_json::from_str(&event.content).unwrap_or(Settings::default()))
|
||||||
|
} else {
|
||||||
|
Err(anyhow!("Not found"))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_settings(&mut self, cx: &mut Context<Self>) {
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let client = nostr.read(cx).client();
|
||||||
|
|
||||||
if let Ok(content) = serde_json::to_string(&self.setting_values) {
|
if let Ok(content) = serde_json::to_string(&self.setting_values) {
|
||||||
let task: Task<Result<(), anyhow::Error>> = cx.background_spawn(async move {
|
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||||
let client = nostr_client();
|
|
||||||
let signer = client.signer().await?;
|
let signer = client.signer().await?;
|
||||||
let public_key = signer.get_public_key().await?;
|
let public_key = signer.get_public_key().await?;
|
||||||
|
|
||||||
@@ -172,14 +201,17 @@ impl AppSettings {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Check if auto authentication is enabled
|
||||||
pub fn is_auto_auth(&self) -> bool {
|
pub fn is_auto_auth(&self) -> bool {
|
||||||
!self.setting_values.authenticated_relays.is_empty() && self.setting_values.auto_auth
|
!self.setting_values.authenticated_relays.is_empty() && self.setting_values.auto_auth
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Check if a relay is authenticated
|
||||||
pub fn is_authenticated(&self, url: &RelayUrl) -> bool {
|
pub fn is_authenticated(&self, url: &RelayUrl) -> bool {
|
||||||
self.setting_values.authenticated_relays.contains(url)
|
self.setting_values.authenticated_relays.contains(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Push a relay to the authenticated relays list
|
||||||
pub fn push_relay(&mut self, relay_url: &RelayUrl, cx: &mut Context<Self>) {
|
pub fn push_relay(&mut self, relay_url: &RelayUrl, cx: &mut Context<Self>) {
|
||||||
if !self.is_authenticated(relay_url) {
|
if !self.is_authenticated(relay_url) {
|
||||||
self.setting_values
|
self.setting_values
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "signer_proxy"
|
|
||||||
version.workspace = true
|
|
||||||
edition.workspace = true
|
|
||||||
publish.workspace = true
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
global = { path = "../global" }
|
|
||||||
|
|
||||||
nostr.workspace = true
|
|
||||||
smol.workspace = true
|
|
||||||
oneshot.workspace = true
|
|
||||||
anyhow.workspace = true
|
|
||||||
log.workspace = true
|
|
||||||
futures.workspace = true
|
|
||||||
smallvec.workspace = true
|
|
||||||
serde.workspace = true
|
|
||||||
serde_json.workspace = true
|
|
||||||
|
|
||||||
atomic-destructor = "0.3.0"
|
|
||||||
uuid = { version = "1.17", features = ["serde", "v4"] }
|
|
||||||
hyper = { version = "1.6", features = ["server", "http1"] }
|
|
||||||
hyper-util = { version = "0.1", features = ["server"] }
|
|
||||||
bytes = "1.10"
|
|
||||||
http-body-util = "0.1"
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<title>NIP-07 Proxy</title>
|
|
||||||
<link rel="stylesheet" href="style.css" />
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<h1>NIP-07 Proxy</h1>
|
|
||||||
<p>
|
|
||||||
This page acts as a proxy between your native application and
|
|
||||||
the NIP-07 browser extension.
|
|
||||||
</p>
|
|
||||||
<div class="status-box">
|
|
||||||
<strong>Status:</strong> <span id="status">Checking...</span>
|
|
||||||
</div>
|
|
||||||
<p>
|
|
||||||
<small
|
|
||||||
>Keep this tab open while using your application. The page
|
|
||||||
will automatically poll for requests from your native
|
|
||||||
app.</small
|
|
||||||
>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h3>Debug Info</h3>
|
|
||||||
<p>
|
|
||||||
<small
|
|
||||||
>Check the browser console (F12) for detailed logs.</small
|
|
||||||
>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="proxy.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
let isPolling = false;
|
|
||||||
|
|
||||||
async function pollForRequests() {
|
|
||||||
if (isPolling) return;
|
|
||||||
isPolling = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/pending");
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
console.log("Polled for requests, got:", data);
|
|
||||||
|
|
||||||
// Process any new requests
|
|
||||||
if (data.requests && data.requests.length > 0) {
|
|
||||||
console.log(`Processing ${data.requests.length} requests`);
|
|
||||||
for (const request of data.requests) {
|
|
||||||
await handleNip07Request(request);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Polling error:", error);
|
|
||||||
updateStatus("Error: " + error.message, "error");
|
|
||||||
}
|
|
||||||
|
|
||||||
isPolling = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleNip07Request(request) {
|
|
||||||
console.log("Handling request:", request);
|
|
||||||
|
|
||||||
try {
|
|
||||||
let result;
|
|
||||||
|
|
||||||
if (!window.nostr) {
|
|
||||||
throw new Error("NIP-07 extension not available");
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (request.method) {
|
|
||||||
case "get_public_key":
|
|
||||||
console.log("Calling nostr.getPublicKey()");
|
|
||||||
result = await window.nostr.getPublicKey();
|
|
||||||
console.log("Got public key:", result);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "sign_event":
|
|
||||||
console.log("Calling nostr.signEvent() with:", request.params);
|
|
||||||
result = await window.nostr.signEvent(request.params);
|
|
||||||
console.log("Got signed event:", result);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "nip04_encrypt":
|
|
||||||
console.log("Calling nostr.nip04.encrypt()");
|
|
||||||
result = await window.nostr.nip04.encrypt(
|
|
||||||
request.params.public_key,
|
|
||||||
request.params.content,
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "nip04_decrypt":
|
|
||||||
console.log("Calling nostr.nip04.decrypt()");
|
|
||||||
result = await window.nostr.nip04.decrypt(
|
|
||||||
request.params.public_key,
|
|
||||||
request.params.content,
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "nip44_encrypt":
|
|
||||||
console.log("Calling nostr.nip44.encrypt()");
|
|
||||||
result = await window.nostr.nip44.encrypt(
|
|
||||||
request.params.public_key,
|
|
||||||
request.params.content,
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "nip44_decrypt":
|
|
||||||
console.log("Calling nostr.nip44.decrypt()");
|
|
||||||
result = await window.nostr.nip44.decrypt(
|
|
||||||
request.params.public_key,
|
|
||||||
request.params.content,
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
throw new Error(`Unknown method: ${request.method}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send response back to server
|
|
||||||
const responsePayload = {
|
|
||||||
id: request.id,
|
|
||||||
result: result,
|
|
||||||
error: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log("Sending response:", responsePayload);
|
|
||||||
|
|
||||||
await fetch("/api/response", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify(responsePayload),
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log("Response sent successfully");
|
|
||||||
updateStatus("Request processed successfully", "connected");
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error handling request:", error);
|
|
||||||
|
|
||||||
// Send error response back to server
|
|
||||||
const errorPayload = {
|
|
||||||
id: request.id,
|
|
||||||
result: null,
|
|
||||||
error: error.message,
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log("Sending error response:", errorPayload);
|
|
||||||
|
|
||||||
await fetch("/api/response", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify(errorPayload),
|
|
||||||
});
|
|
||||||
|
|
||||||
updateStatus("Error: " + error.message, "error");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateStatus(message, className) {
|
|
||||||
const statusEl = document.getElementById("status");
|
|
||||||
statusEl.textContent = message;
|
|
||||||
statusEl.className = className;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start polling when page loads
|
|
||||||
window.addEventListener("load", () => {
|
|
||||||
console.log("NIP-07 Proxy loaded");
|
|
||||||
|
|
||||||
// Check if NIP-07 extension is available
|
|
||||||
if (window.nostr) {
|
|
||||||
console.log("NIP-07 extension detected");
|
|
||||||
updateStatus("Connected to NIP-07 extension - Ready", "connected");
|
|
||||||
} else {
|
|
||||||
console.log("NIP-07 extension not found");
|
|
||||||
updateStatus("NIP-07 extension not found", "error");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start polling every 500 ms
|
|
||||||
setInterval(pollForRequests, 500);
|
|
||||||
});
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
use std::{fmt, io};
|
|
||||||
|
|
||||||
use hyper::http;
|
|
||||||
use nostr::event;
|
|
||||||
use oneshot::RecvError;
|
|
||||||
|
|
||||||
/// Error
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub enum Error {
|
|
||||||
/// I/O error
|
|
||||||
Io(io::Error),
|
|
||||||
/// HTTP error
|
|
||||||
Http(http::Error),
|
|
||||||
/// Json error
|
|
||||||
Json(serde_json::Error),
|
|
||||||
/// Event error
|
|
||||||
Event(event::Error),
|
|
||||||
/// Oneshot channel receive error
|
|
||||||
OneShotRecv(RecvError),
|
|
||||||
/// Generic error
|
|
||||||
Generic(String),
|
|
||||||
/// Timeout
|
|
||||||
Timeout,
|
|
||||||
/// The server is shutdown
|
|
||||||
Shutdown,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::error::Error for Error {}
|
|
||||||
|
|
||||||
impl fmt::Display for Error {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
match self {
|
|
||||||
Self::Io(e) => write!(f, "{e}"),
|
|
||||||
Self::Http(e) => write!(f, "{e}"),
|
|
||||||
Self::Json(e) => write!(f, "{e}"),
|
|
||||||
Self::Event(e) => write!(f, "{e}"),
|
|
||||||
Self::OneShotRecv(e) => write!(f, "{e}"),
|
|
||||||
Self::Generic(e) => write!(f, "{e}"),
|
|
||||||
Self::Timeout => write!(f, "timeout"),
|
|
||||||
Self::Shutdown => write!(f, "server is shutdown"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<io::Error> for Error {
|
|
||||||
fn from(e: io::Error) -> Self {
|
|
||||||
Self::Io(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<http::Error> for Error {
|
|
||||||
fn from(e: http::Error) -> Self {
|
|
||||||
Self::Http(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<serde_json::Error> for Error {
|
|
||||||
fn from(e: serde_json::Error) -> Self {
|
|
||||||
Self::Json(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<event::Error> for Error {
|
|
||||||
fn from(e: event::Error) -> Self {
|
|
||||||
Self::Event(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<RecvError> for Error {
|
|
||||||
fn from(e: RecvError) -> Self {
|
|
||||||
Self::OneShotRecv(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,678 +0,0 @@
|
|||||||
use std::collections::HashMap;
|
|
||||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr, SocketAddrV4, TcpListener};
|
|
||||||
use std::pin::Pin;
|
|
||||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
|
||||||
use std::sync::Arc;
|
|
||||||
use std::task::{Context, Poll};
|
|
||||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
|
||||||
|
|
||||||
use atomic_destructor::{AtomicDestroyer, AtomicDestructor};
|
|
||||||
use bytes::Bytes;
|
|
||||||
use futures::FutureExt;
|
|
||||||
use http_body_util::combinators::BoxBody;
|
|
||||||
use http_body_util::{BodyExt, Full};
|
|
||||||
use hyper::body::Incoming;
|
|
||||||
use hyper::server::conn::http1;
|
|
||||||
use hyper::service::service_fn;
|
|
||||||
use hyper::{Method, Request, Response, StatusCode};
|
|
||||||
use nostr::prelude::{BoxedFuture, SignerBackend};
|
|
||||||
use nostr::{Event, NostrSigner, PublicKey, SignerError, UnsignedEvent};
|
|
||||||
use serde::de::DeserializeOwned;
|
|
||||||
use serde::{Deserialize, Serialize, Serializer};
|
|
||||||
use serde_json::{json, Value};
|
|
||||||
use smol::io::{AsyncRead, AsyncWrite};
|
|
||||||
use smol::lock::Mutex;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::error::Error;
|
|
||||||
|
|
||||||
mod error;
|
|
||||||
|
|
||||||
const HTML: &str = include_str!("../index.html");
|
|
||||||
const JS: &str = include_str!("../proxy.js");
|
|
||||||
const CSS: &str = include_str!("../style.css");
|
|
||||||
|
|
||||||
/// Wrapper to make smol::Async<TcpStream> compatible with hyper
|
|
||||||
struct HyperIo<T> {
|
|
||||||
inner: T,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<T> HyperIo<T> {
|
|
||||||
fn new(inner: T) -> Self {
|
|
||||||
Self { inner }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<T: AsyncRead + Unpin> hyper::rt::Read for HyperIo<T> {
|
|
||||||
fn poll_read(
|
|
||||||
mut self: Pin<&mut Self>,
|
|
||||||
cx: &mut Context<'_>,
|
|
||||||
mut buf: hyper::rt::ReadBufCursor<'_>,
|
|
||||||
) -> Poll<Result<(), std::io::Error>> {
|
|
||||||
let mut tbuf = vec![0; buf.remaining()];
|
|
||||||
match Pin::new(&mut self.inner).poll_read(cx, &mut tbuf) {
|
|
||||||
Poll::Ready(Ok(n)) => {
|
|
||||||
buf.put_slice(&tbuf[..n]);
|
|
||||||
Poll::Ready(Ok(()))
|
|
||||||
}
|
|
||||||
Poll::Ready(Err(e)) => Poll::Ready(Err(e)),
|
|
||||||
Poll::Pending => Poll::Pending,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<T: AsyncWrite + Unpin> hyper::rt::Write for HyperIo<T> {
|
|
||||||
fn poll_write(
|
|
||||||
mut self: Pin<&mut Self>,
|
|
||||||
cx: &mut Context<'_>,
|
|
||||||
buf: &[u8],
|
|
||||||
) -> Poll<Result<usize, std::io::Error>> {
|
|
||||||
Pin::new(&mut self.inner).poll_write(cx, buf)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn poll_flush(
|
|
||||||
mut self: Pin<&mut Self>,
|
|
||||||
cx: &mut Context<'_>,
|
|
||||||
) -> Poll<Result<(), std::io::Error>> {
|
|
||||||
Pin::new(&mut self.inner).poll_flush(cx)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn poll_shutdown(
|
|
||||||
mut self: Pin<&mut Self>,
|
|
||||||
cx: &mut Context<'_>,
|
|
||||||
) -> Poll<Result<(), std::io::Error>> {
|
|
||||||
Pin::new(&mut self.inner).poll_close(cx)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type PendingResponseMap = HashMap<Uuid, oneshot::Sender<Result<Value, String>>>;
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct Message {
|
|
||||||
id: Uuid,
|
|
||||||
error: Option<String>,
|
|
||||||
result: Option<Value>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Message {
|
|
||||||
fn into_result(self) -> Result<Value, String> {
|
|
||||||
if let Some(error) = self.error {
|
|
||||||
Err(error)
|
|
||||||
} else {
|
|
||||||
Ok(self.result.unwrap_or(Value::Null))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
|
||||||
enum RequestMethod {
|
|
||||||
GetPublicKey,
|
|
||||||
SignEvent,
|
|
||||||
Nip04Encrypt,
|
|
||||||
Nip04Decrypt,
|
|
||||||
Nip44Encrypt,
|
|
||||||
Nip44Decrypt,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RequestMethod {
|
|
||||||
fn as_str(&self) -> &str {
|
|
||||||
match self {
|
|
||||||
Self::GetPublicKey => "get_public_key",
|
|
||||||
Self::SignEvent => "sign_event",
|
|
||||||
Self::Nip04Encrypt => "nip04_encrypt",
|
|
||||||
Self::Nip04Decrypt => "nip04_decrypt",
|
|
||||||
Self::Nip44Encrypt => "nip44_encrypt",
|
|
||||||
Self::Nip44Decrypt => "nip44_decrypt",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Serialize for RequestMethod {
|
|
||||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
|
||||||
where
|
|
||||||
S: Serializer,
|
|
||||||
{
|
|
||||||
serializer.serialize_str(self.as_str())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
|
||||||
struct RequestData {
|
|
||||||
id: Uuid,
|
|
||||||
method: RequestMethod,
|
|
||||||
params: Value,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RequestData {
|
|
||||||
#[inline]
|
|
||||||
fn new(method: RequestMethod, params: Value) -> Self {
|
|
||||||
Self {
|
|
||||||
id: Uuid::new_v4(),
|
|
||||||
method,
|
|
||||||
params,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
struct Requests<'a> {
|
|
||||||
requests: &'a [RequestData],
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a> Requests<'a> {
|
|
||||||
#[inline]
|
|
||||||
fn new(requests: &'a [RequestData]) -> Self {
|
|
||||||
Self { requests }
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
fn len(&self) -> usize {
|
|
||||||
self.requests.len()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Params for NIP-04 and NIP-44 encryption/decryption
|
|
||||||
#[derive(Serialize)]
|
|
||||||
struct CryptoParams<'a> {
|
|
||||||
public_key: &'a PublicKey,
|
|
||||||
content: &'a str,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a> CryptoParams<'a> {
|
|
||||||
#[inline]
|
|
||||||
fn new(public_key: &'a PublicKey, content: &'a str) -> Self {
|
|
||||||
Self {
|
|
||||||
public_key,
|
|
||||||
content,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
struct ProxyState {
|
|
||||||
/// Requests waiting to be picked up by browser
|
|
||||||
pub outgoing_requests: Mutex<Vec<RequestData>>,
|
|
||||||
/// Map of request ID to response sender
|
|
||||||
pub pending_responses: Mutex<PendingResponseMap>,
|
|
||||||
/// Last time the client ask for the pending requests
|
|
||||||
pub last_pending_request: Arc<AtomicU64>,
|
|
||||||
/// Notification for shutdown
|
|
||||||
pub shutdown_notify: smol::channel::Receiver<()>,
|
|
||||||
pub shutdown_sender: smol::channel::Sender<()>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Configuration options for [`BrowserSignerProxy`].
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct BrowserSignerProxyOptions {
|
|
||||||
/// Request timeout for the signer extension. Default is 30 seconds.
|
|
||||||
pub timeout: Duration,
|
|
||||||
/// Proxy server IP address and port. Default is `127.0.0.1:7400`.
|
|
||||||
pub addr: SocketAddr,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
struct InnerBrowserSignerProxy {
|
|
||||||
/// Configuration options for the proxy
|
|
||||||
options: BrowserSignerProxyOptions,
|
|
||||||
/// Internal state of the proxy including request queues
|
|
||||||
state: Arc<ProxyState>,
|
|
||||||
/// Flag to indicate if the server is shutdown
|
|
||||||
is_shutdown: Arc<AtomicBool>,
|
|
||||||
/// Flat indicating if the server is started
|
|
||||||
is_started: Arc<AtomicBool>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AtomicDestroyer for InnerBrowserSignerProxy {
|
|
||||||
fn on_destroy(&self) {
|
|
||||||
self.shutdown();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl InnerBrowserSignerProxy {
|
|
||||||
#[inline]
|
|
||||||
fn is_shutdown(&self) -> bool {
|
|
||||||
self.is_shutdown.load(Ordering::SeqCst)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn shutdown(&self) {
|
|
||||||
// Mark the server as shutdown
|
|
||||||
self.is_shutdown.store(true, Ordering::SeqCst);
|
|
||||||
|
|
||||||
// Notify all waiters that the proxy is shutting down
|
|
||||||
let _ = self.state.shutdown_sender.try_send(());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Nostr Browser Signer Proxy
|
|
||||||
///
|
|
||||||
/// Proxy to use Nostr Browser signer (NIP-07) in native applications.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct BrowserSignerProxy {
|
|
||||||
inner: AtomicDestructor<InnerBrowserSignerProxy>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for BrowserSignerProxyOptions {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
timeout: Duration::from_secs(30),
|
|
||||||
// 7 for NIP-07 and 400 because the NIP title is 40 bytes :)
|
|
||||||
addr: SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 7400)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl BrowserSignerProxyOptions {
|
|
||||||
/// Sets the timeout duration.
|
|
||||||
pub const fn timeout(mut self, timeout: Duration) -> Self {
|
|
||||||
self.timeout = timeout;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sets the IP address.
|
|
||||||
pub const fn ip_addr(mut self, new_ip: IpAddr) -> Self {
|
|
||||||
self.addr = SocketAddr::new(new_ip, self.addr.port());
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sets the port number.
|
|
||||||
pub const fn port(mut self, new_port: u16) -> Self {
|
|
||||||
self.addr = SocketAddr::new(self.addr.ip(), new_port);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl BrowserSignerProxy {
|
|
||||||
/// Construct a new browser signer proxy
|
|
||||||
pub fn new(options: BrowserSignerProxyOptions) -> Self {
|
|
||||||
let (shutdown_sender, shutdown_notify) = smol::channel::unbounded();
|
|
||||||
let state = ProxyState {
|
|
||||||
outgoing_requests: Mutex::new(Vec::new()),
|
|
||||||
pending_responses: Mutex::new(HashMap::new()),
|
|
||||||
last_pending_request: Arc::new(AtomicU64::new(0)),
|
|
||||||
shutdown_notify,
|
|
||||||
shutdown_sender,
|
|
||||||
};
|
|
||||||
|
|
||||||
Self {
|
|
||||||
inner: AtomicDestructor::new(InnerBrowserSignerProxy {
|
|
||||||
options,
|
|
||||||
state: Arc::new(state),
|
|
||||||
is_shutdown: Arc::new(AtomicBool::new(false)),
|
|
||||||
is_started: Arc::new(AtomicBool::new(false)),
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Indicates whether the server is currently running.
|
|
||||||
#[inline]
|
|
||||||
pub fn is_started(&self) -> bool {
|
|
||||||
self.inner.is_started.load(Ordering::SeqCst)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Checks if there is an open browser tap ready to respond to requests by
|
|
||||||
/// verifying the time since the last pending request.
|
|
||||||
#[inline]
|
|
||||||
pub fn is_session_active(&self) -> bool {
|
|
||||||
current_time() - self.inner.state.last_pending_request.load(Ordering::SeqCst) < 2
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the signer proxy webpage URL
|
|
||||||
#[inline]
|
|
||||||
pub fn url(&self) -> String {
|
|
||||||
format!("http://{}", self.inner.options.addr)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Start the proxy
|
|
||||||
///
|
|
||||||
/// If this is not called, will be automatically started on the first interaction with the signer.
|
|
||||||
pub async fn start(&self) -> Result<(), Error> {
|
|
||||||
// Ensure is not shutdown
|
|
||||||
if self.inner.is_shutdown() {
|
|
||||||
return Err(Error::Shutdown);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mark the proxy as started and check if was already started
|
|
||||||
let is_started: bool = self.inner.is_started.swap(true, Ordering::SeqCst);
|
|
||||||
|
|
||||||
// Immediately return if already started
|
|
||||||
if is_started {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let listener = match smol::Async::<TcpListener>::bind(self.inner.options.addr) {
|
|
||||||
Ok(listener) => listener,
|
|
||||||
Err(e) => {
|
|
||||||
// Undo the started flag if binding fails
|
|
||||||
self.inner.is_started.store(false, Ordering::SeqCst);
|
|
||||||
|
|
||||||
// Propagate error
|
|
||||||
return Err(Error::from(e));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let addr: SocketAddr = self.inner.options.addr;
|
|
||||||
let state: Arc<ProxyState> = self.inner.state.clone();
|
|
||||||
|
|
||||||
smol::spawn(async move {
|
|
||||||
log::info!("Starting proxy server on {addr}");
|
|
||||||
|
|
||||||
loop {
|
|
||||||
futures::select! {
|
|
||||||
accept_result = listener.accept().fuse() => {
|
|
||||||
let (stream, _) = match accept_result {
|
|
||||||
Ok(conn) => conn,
|
|
||||||
Err(e) => {
|
|
||||||
log::error!("Failed to accept connection: {e}");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let io = HyperIo::new(stream);
|
|
||||||
let state: Arc<ProxyState> = state.clone();
|
|
||||||
let shutdown_notify = state.shutdown_notify.clone();
|
|
||||||
|
|
||||||
smol::spawn(async move {
|
|
||||||
let service = service_fn(move |req| {
|
|
||||||
handle_request(req, state.clone())
|
|
||||||
});
|
|
||||||
|
|
||||||
futures::select! {
|
|
||||||
res = http1::Builder::new().serve_connection(io, service).fuse() => {
|
|
||||||
if let Err(e) = res {
|
|
||||||
log::error!("Error serving connection: {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ = shutdown_notify.recv().fuse() => {
|
|
||||||
log::debug!("Closing connection, proxy server is shutting down.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}).detach();
|
|
||||||
},
|
|
||||||
_ = state.shutdown_notify.recv().fuse() => {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log::info!("Shutting down proxy server.");
|
|
||||||
}).detach();
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
async fn store_pending_response(&self, id: Uuid, tx: oneshot::Sender<Result<Value, String>>) {
|
|
||||||
let mut pending_responses = self.inner.state.pending_responses.lock().await;
|
|
||||||
pending_responses.insert(id, tx);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
async fn store_outgoing_request(&self, request: RequestData) {
|
|
||||||
let mut outgoing_requests = self.inner.state.outgoing_requests.lock().await;
|
|
||||||
outgoing_requests.push(request);
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn request<T>(&self, method: RequestMethod, params: Value) -> Result<T, Error>
|
|
||||||
where
|
|
||||||
T: DeserializeOwned,
|
|
||||||
{
|
|
||||||
// Start the proxy if not already started
|
|
||||||
self.start().await?;
|
|
||||||
|
|
||||||
// Construct the request
|
|
||||||
let request: RequestData = RequestData::new(method, params);
|
|
||||||
|
|
||||||
// Create a oneshot channel
|
|
||||||
let (tx, rx) = oneshot::channel();
|
|
||||||
|
|
||||||
// Store the response sender
|
|
||||||
self.store_pending_response(request.id, tx).await;
|
|
||||||
|
|
||||||
// Add to outgoing requests queue
|
|
||||||
self.store_outgoing_request(request).await;
|
|
||||||
|
|
||||||
// Wait for response
|
|
||||||
let timeout_fut = smol::Timer::after(self.inner.options.timeout);
|
|
||||||
let recv_fut = rx;
|
|
||||||
|
|
||||||
match futures::future::select(timeout_fut, recv_fut).await {
|
|
||||||
futures::future::Either::Left(_) => Err(Error::Timeout),
|
|
||||||
futures::future::Either::Right((recv_result, _)) => {
|
|
||||||
match recv_result.map_err(|_| Error::Generic("Channel closed".to_string()))? {
|
|
||||||
Ok(res) => Ok(serde_json::from_value(res)?),
|
|
||||||
Err(error) => Err(Error::Generic(error)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
async fn _get_public_key(&self) -> Result<PublicKey, Error> {
|
|
||||||
self.request(RequestMethod::GetPublicKey, json!({})).await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
async fn _sign_event(&self, event: UnsignedEvent) -> Result<Event, Error> {
|
|
||||||
let event: Event = self
|
|
||||||
.request(RequestMethod::SignEvent, serde_json::to_value(event)?)
|
|
||||||
.await?;
|
|
||||||
event.verify()?;
|
|
||||||
Ok(event)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
async fn _nip04_encrypt(&self, public_key: &PublicKey, content: &str) -> Result<String, Error> {
|
|
||||||
let params = CryptoParams::new(public_key, content);
|
|
||||||
self.request(RequestMethod::Nip04Encrypt, serde_json::to_value(params)?)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
async fn _nip04_decrypt(&self, public_key: &PublicKey, content: &str) -> Result<String, Error> {
|
|
||||||
let params = CryptoParams::new(public_key, content);
|
|
||||||
self.request(RequestMethod::Nip04Decrypt, serde_json::to_value(params)?)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
async fn _nip44_encrypt(&self, public_key: &PublicKey, content: &str) -> Result<String, Error> {
|
|
||||||
let params = CryptoParams::new(public_key, content);
|
|
||||||
self.request(RequestMethod::Nip44Encrypt, serde_json::to_value(params)?)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
async fn _nip44_decrypt(&self, public_key: &PublicKey, content: &str) -> Result<String, Error> {
|
|
||||||
let params = CryptoParams::new(public_key, content);
|
|
||||||
self.request(RequestMethod::Nip44Decrypt, serde_json::to_value(params)?)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl NostrSigner for BrowserSignerProxy {
|
|
||||||
fn backend(&self) -> SignerBackend {
|
|
||||||
SignerBackend::BrowserExtension
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
fn get_public_key(&self) -> BoxedFuture<Result<PublicKey, SignerError>> {
|
|
||||||
Box::pin(async move { self._get_public_key().await.map_err(SignerError::backend) })
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
fn sign_event(&self, unsigned: UnsignedEvent) -> BoxedFuture<Result<Event, SignerError>> {
|
|
||||||
Box::pin(async move {
|
|
||||||
self._sign_event(unsigned)
|
|
||||||
.await
|
|
||||||
.map_err(SignerError::backend)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
fn nip04_encrypt<'a>(
|
|
||||||
&'a self,
|
|
||||||
public_key: &'a PublicKey,
|
|
||||||
content: &'a str,
|
|
||||||
) -> BoxedFuture<'a, Result<String, SignerError>> {
|
|
||||||
Box::pin(async move {
|
|
||||||
self._nip04_encrypt(public_key, content)
|
|
||||||
.await
|
|
||||||
.map_err(SignerError::backend)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
fn nip04_decrypt<'a>(
|
|
||||||
&'a self,
|
|
||||||
public_key: &'a PublicKey,
|
|
||||||
encrypted_content: &'a str,
|
|
||||||
) -> BoxedFuture<'a, Result<String, SignerError>> {
|
|
||||||
Box::pin(async move {
|
|
||||||
self._nip04_decrypt(public_key, encrypted_content)
|
|
||||||
.await
|
|
||||||
.map_err(SignerError::backend)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
fn nip44_encrypt<'a>(
|
|
||||||
&'a self,
|
|
||||||
public_key: &'a PublicKey,
|
|
||||||
content: &'a str,
|
|
||||||
) -> BoxedFuture<'a, Result<String, SignerError>> {
|
|
||||||
Box::pin(async move {
|
|
||||||
self._nip44_encrypt(public_key, content)
|
|
||||||
.await
|
|
||||||
.map_err(SignerError::backend)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
fn nip44_decrypt<'a>(
|
|
||||||
&'a self,
|
|
||||||
public_key: &'a PublicKey,
|
|
||||||
payload: &'a str,
|
|
||||||
) -> BoxedFuture<'a, Result<String, SignerError>> {
|
|
||||||
Box::pin(async move {
|
|
||||||
self._nip44_decrypt(public_key, payload)
|
|
||||||
.await
|
|
||||||
.map_err(SignerError::backend)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn handle_request(
|
|
||||||
req: Request<Incoming>,
|
|
||||||
state: Arc<ProxyState>,
|
|
||||||
) -> Result<Response<BoxBody<Bytes, Error>>, Error> {
|
|
||||||
match (req.method(), req.uri().path()) {
|
|
||||||
// Serve the HTML proxy page
|
|
||||||
(&Method::GET, "/") => Ok(Response::builder()
|
|
||||||
.header("Content-Type", "text/html")
|
|
||||||
.body(full(HTML))?),
|
|
||||||
// Serve the CSS page style
|
|
||||||
(&Method::GET, "/style.css") => Ok(Response::builder()
|
|
||||||
.header("Content-Type", "text/css")
|
|
||||||
.body(full(CSS))?),
|
|
||||||
// Serve the JS proxy script
|
|
||||||
(&Method::GET, "/proxy.js") => Ok(Response::builder()
|
|
||||||
.header("Content-Type", "application/javascript")
|
|
||||||
.body(full(JS))?),
|
|
||||||
// Browser polls this endpoint to get pending requests
|
|
||||||
(&Method::GET, "/api/pending") => {
|
|
||||||
state
|
|
||||||
.last_pending_request
|
|
||||||
.store(current_time(), Ordering::SeqCst);
|
|
||||||
|
|
||||||
let mut outgoing = state.outgoing_requests.lock().await;
|
|
||||||
|
|
||||||
let requests: Requests<'_> = Requests::new(&outgoing);
|
|
||||||
let json: String = serde_json::to_string(&requests)?;
|
|
||||||
|
|
||||||
log::debug!("Sending {} pending requests to browser", requests.len());
|
|
||||||
|
|
||||||
// Clear the outgoing requests after sending them
|
|
||||||
outgoing.clear();
|
|
||||||
|
|
||||||
Ok(Response::builder()
|
|
||||||
.header("Content-Type", "application/json")
|
|
||||||
.header("Access-Control-Allow-Origin", "*")
|
|
||||||
.body(full(json))?)
|
|
||||||
}
|
|
||||||
// Get response
|
|
||||||
(&Method::POST, "/api/response") => {
|
|
||||||
// Correctly collect the body bytes from the stream
|
|
||||||
let body_bytes: Bytes = match req.into_body().collect().await {
|
|
||||||
Ok(collected) => collected.to_bytes(),
|
|
||||||
Err(e) => {
|
|
||||||
log::error!("Failed to read body: {e}");
|
|
||||||
let response = Response::builder()
|
|
||||||
.status(StatusCode::BAD_REQUEST)
|
|
||||||
.body(full("Failed to read body"))?;
|
|
||||||
return Ok(response);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle responses from the browser extension
|
|
||||||
let message: Message = match serde_json::from_slice(&body_bytes) {
|
|
||||||
Ok(json) => json,
|
|
||||||
Err(_) => {
|
|
||||||
let response = Response::builder()
|
|
||||||
.status(StatusCode::BAD_REQUEST)
|
|
||||||
.body(full("Invalid JSON"))?;
|
|
||||||
return Ok(response);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
log::debug!("Received response from browser: {message:?}");
|
|
||||||
|
|
||||||
let id: Uuid = message.id;
|
|
||||||
let mut pending = state.pending_responses.lock().await;
|
|
||||||
|
|
||||||
match pending.remove(&id) {
|
|
||||||
Some(sender) => {
|
|
||||||
let _ = sender.send(message.into_result());
|
|
||||||
}
|
|
||||||
None => log::warn!("No pending request found for {id}"),
|
|
||||||
}
|
|
||||||
|
|
||||||
let response = Response::builder()
|
|
||||||
.header("Access-Control-Allow-Origin", "*")
|
|
||||||
.body(full("OK"))?;
|
|
||||||
Ok(response)
|
|
||||||
}
|
|
||||||
(&Method::OPTIONS, _) => {
|
|
||||||
// Handle CORS preflight requests
|
|
||||||
let response = Response::builder()
|
|
||||||
.header("Access-Control-Allow-Origin", "*")
|
|
||||||
.header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
|
||||||
.header("Access-Control-Allow-Headers", "Content-Type")
|
|
||||||
.body(full(""))?;
|
|
||||||
Ok(response)
|
|
||||||
}
|
|
||||||
// 404 - not found
|
|
||||||
_ => {
|
|
||||||
let response = Response::builder()
|
|
||||||
.status(StatusCode::NOT_FOUND)
|
|
||||||
.body(full("Not Found"))?;
|
|
||||||
Ok(response)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
fn full<T: Into<Bytes>>(chunk: T) -> BoxBody<Bytes, Error> {
|
|
||||||
Full::new(chunk.into())
|
|
||||||
.map_err(|never| match never {})
|
|
||||||
.boxed()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Gets the current time in seconds since the Unix epoch (1970-01-01). If the
|
|
||||||
/// time is before the epoch, returns 0.
|
|
||||||
#[inline]
|
|
||||||
fn current_time() -> u64 {
|
|
||||||
SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.map(|d| d.as_secs())
|
|
||||||
.unwrap_or_default()
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
body {
|
|
||||||
font-family:
|
|
||||||
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans,
|
|
||||||
Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
|
|
||||||
padding: 20px;
|
|
||||||
background-color: #f0f0f0;
|
|
||||||
}
|
|
||||||
.container {
|
|
||||||
max-width: 600px;
|
|
||||||
margin: 0 auto;
|
|
||||||
background: white;
|
|
||||||
padding: 20px;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
.connected {
|
|
||||||
color: green;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
.error {
|
|
||||||
color: red;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
.status-box {
|
|
||||||
background: #f9f9f9;
|
|
||||||
padding: 10px;
|
|
||||||
border-radius: 4px;
|
|
||||||
margin: 10px 0;
|
|
||||||
border-left: 4px solid #ccc;
|
|
||||||
}
|
|
||||||
@@ -1,16 +1,21 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "global"
|
name = "state"
|
||||||
version.workspace = true
|
version.workspace = true
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
publish.workspace = true
|
publish.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
common = { path = "../common" }
|
||||||
|
|
||||||
nostr-sdk.workspace = true
|
nostr-sdk.workspace = true
|
||||||
dirs.workspace = true
|
nostr-lmdb.workspace = true
|
||||||
|
|
||||||
|
gpui.workspace = true
|
||||||
smol.workspace = true
|
smol.workspace = true
|
||||||
flume.workspace = true
|
smallvec.workspace = true
|
||||||
log.workspace = true
|
log.workspace = true
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
|
||||||
whoami = "1.5.2"
|
|
||||||
rustls = "0.23.23"
|
rustls = "0.23.23"
|
||||||
@@ -0,0 +1,388 @@
|
|||||||
|
use std::collections::HashSet;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use anyhow::{anyhow, Context as AnyhowContext, Error};
|
||||||
|
use common::{config_dir, BOOTSTRAP_RELAYS, SEARCH_RELAYS};
|
||||||
|
use gpui::{App, AppContext, Context, Entity, Global, Task};
|
||||||
|
use nostr_lmdb::NostrLmdb;
|
||||||
|
use nostr_sdk::prelude::*;
|
||||||
|
use smallvec::{smallvec, SmallVec};
|
||||||
|
use smol::lock::RwLock;
|
||||||
|
pub use storage::*;
|
||||||
|
pub use tracker::*;
|
||||||
|
|
||||||
|
mod storage;
|
||||||
|
mod tracker;
|
||||||
|
|
||||||
|
pub const GIFTWRAP_SUBSCRIPTION: &str = "gift-wrap-events";
|
||||||
|
|
||||||
|
pub fn init(cx: &mut App) {
|
||||||
|
NostrRegistry::set_global(cx.new(NostrRegistry::new), cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
struct GlobalNostrRegistry(Entity<NostrRegistry>);
|
||||||
|
|
||||||
|
impl Global for GlobalNostrRegistry {}
|
||||||
|
|
||||||
|
/// Nostr Registry
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct NostrRegistry {
|
||||||
|
/// Nostr Client
|
||||||
|
client: Client,
|
||||||
|
|
||||||
|
/// Custom gossip implementation
|
||||||
|
gossip: Arc<RwLock<Gossip>>,
|
||||||
|
|
||||||
|
/// Tracks activity related to Nostr events
|
||||||
|
tracker: Arc<RwLock<EventTracker>>,
|
||||||
|
|
||||||
|
/// Tasks for asynchronous operations
|
||||||
|
_tasks: SmallVec<[Task<()>; 1]>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NostrRegistry {
|
||||||
|
/// Retrieve the global nostr state
|
||||||
|
pub fn global(cx: &App) -> Entity<Self> {
|
||||||
|
cx.global::<GlobalNostrRegistry>().0.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the global nostr instance
|
||||||
|
fn set_global(state: Entity<Self>, cx: &mut App) {
|
||||||
|
cx.set_global(GlobalNostrRegistry(state));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new nostr instance
|
||||||
|
fn new(cx: &mut Context<Self>) -> Self {
|
||||||
|
// rustls uses the `aws_lc_rs` provider by default
|
||||||
|
// This only errors if the default provider has already
|
||||||
|
// been installed. We can ignore this `Result`.
|
||||||
|
rustls::crypto::aws_lc_rs::default_provider()
|
||||||
|
.install_default()
|
||||||
|
.ok();
|
||||||
|
|
||||||
|
// Construct the nostr client options
|
||||||
|
let opts = ClientOptions::new()
|
||||||
|
.automatic_authentication(false)
|
||||||
|
.verify_subscriptions(false)
|
||||||
|
.sleep_when_idle(SleepWhenIdle::Enabled {
|
||||||
|
timeout: Duration::from_secs(600),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Construct the lmdb
|
||||||
|
let lmdb = cx.background_executor().block(async move {
|
||||||
|
let path = config_dir().join("nostr");
|
||||||
|
NostrLmdb::open(path)
|
||||||
|
.await
|
||||||
|
.expect("Failed to initialize database")
|
||||||
|
});
|
||||||
|
|
||||||
|
// Construct the nostr client
|
||||||
|
let client = ClientBuilder::default().database(lmdb).opts(opts).build();
|
||||||
|
|
||||||
|
let tracker = Arc::new(RwLock::new(EventTracker::default()));
|
||||||
|
let gossip = Arc::new(RwLock::new(Gossip::default()));
|
||||||
|
|
||||||
|
let mut tasks = smallvec![];
|
||||||
|
|
||||||
|
tasks.push(
|
||||||
|
// Establish connection to the bootstrap relays
|
||||||
|
//
|
||||||
|
// And handle notifications from the nostr relay pool channel
|
||||||
|
cx.background_spawn({
|
||||||
|
let client = client.clone();
|
||||||
|
let gossip = Arc::clone(&gossip);
|
||||||
|
let tracker = Arc::clone(&tracker);
|
||||||
|
let _ = initialized_at();
|
||||||
|
|
||||||
|
async move {
|
||||||
|
// Connect to the bootstrap relays
|
||||||
|
Self::connect(&client).await;
|
||||||
|
|
||||||
|
// Handle notifications from the relay pool
|
||||||
|
Self::handle_notifications(&client, &gossip, &tracker).await;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
client,
|
||||||
|
tracker,
|
||||||
|
gossip,
|
||||||
|
_tasks: tasks,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Establish connection to the bootstrap relays
|
||||||
|
async fn connect(client: &Client) {
|
||||||
|
// Get all bootstrapping relays
|
||||||
|
let mut urls = vec![];
|
||||||
|
urls.extend(BOOTSTRAP_RELAYS);
|
||||||
|
urls.extend(SEARCH_RELAYS);
|
||||||
|
|
||||||
|
// Add relay to the relay pool
|
||||||
|
for url in urls.into_iter() {
|
||||||
|
client.add_relay(url).await.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect to all added relays
|
||||||
|
client.connect().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_notifications(
|
||||||
|
client: &Client,
|
||||||
|
gossip: &Arc<RwLock<Gossip>>,
|
||||||
|
tracker: &Arc<RwLock<EventTracker>>,
|
||||||
|
) {
|
||||||
|
let mut notifications = client.notifications();
|
||||||
|
let mut processed_events = HashSet::new();
|
||||||
|
|
||||||
|
while let Ok(notification) = notifications.recv().await {
|
||||||
|
let RelayPoolNotification::Message { message, relay_url } = notification else {
|
||||||
|
// Skip if the notification is not a message
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
match message {
|
||||||
|
RelayMessage::Event { event, .. } => {
|
||||||
|
if !processed_events.insert(event.id) {
|
||||||
|
// Skip if the event has already been processed
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
match event.kind {
|
||||||
|
Kind::RelayList => {
|
||||||
|
let mut gossip = gossip.write().await;
|
||||||
|
gossip.insert_relays(&event);
|
||||||
|
|
||||||
|
let urls: Vec<RelayUrl> = Self::extract_write_relays(&event);
|
||||||
|
let author = event.pubkey;
|
||||||
|
|
||||||
|
log::info!("Write relays: {urls:?}");
|
||||||
|
|
||||||
|
// Fetch user's encryption announcement event
|
||||||
|
Self::get(client, &urls, author, Kind::Custom(10044)).await;
|
||||||
|
// Fetch user's messaging relays event
|
||||||
|
Self::get(client, &urls, author, Kind::InboxRelays).await;
|
||||||
|
|
||||||
|
// Verify if the event is belonging to the current user
|
||||||
|
if Self::is_self_authored(client, &event).await {
|
||||||
|
// Fetch user's metadata event
|
||||||
|
Self::get(client, &urls, author, Kind::Metadata).await;
|
||||||
|
// Fetch user's contact list event
|
||||||
|
Self::get(client, &urls, author, Kind::ContactList).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Kind::InboxRelays => {
|
||||||
|
let mut gossip = gossip.write().await;
|
||||||
|
gossip.insert_messaging_relays(&event);
|
||||||
|
|
||||||
|
if Self::is_self_authored(client, &event).await {
|
||||||
|
// Extract user's messaging relays
|
||||||
|
let urls: Vec<RelayUrl> =
|
||||||
|
nip17::extract_relay_list(&event).cloned().collect();
|
||||||
|
|
||||||
|
// Fetch user's inbox messages in the extracted relays
|
||||||
|
Self::get_messages(client, event.pubkey, &urls).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Kind::Custom(10044) => {
|
||||||
|
let mut gossip = gossip.write().await;
|
||||||
|
gossip.insert_announcement(&event);
|
||||||
|
}
|
||||||
|
Kind::ContactList => {
|
||||||
|
if Self::is_self_authored(client, &event).await {
|
||||||
|
let public_keys: Vec<PublicKey> =
|
||||||
|
event.tags.public_keys().copied().collect();
|
||||||
|
|
||||||
|
if let Err(e) =
|
||||||
|
Self::get_metadata_for_list(client, public_keys).await
|
||||||
|
{
|
||||||
|
log::error!("Failed to get metadata for list: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
RelayMessage::Ok {
|
||||||
|
event_id, message, ..
|
||||||
|
} => {
|
||||||
|
let msg = MachineReadablePrefix::parse(&message);
|
||||||
|
let mut tracker = tracker.write().await;
|
||||||
|
|
||||||
|
// Message that need to be authenticated will be handled separately
|
||||||
|
if let Some(MachineReadablePrefix::AuthRequired) = msg {
|
||||||
|
// Keep track of events that need to be resent after authentication
|
||||||
|
tracker.resend_queue.insert(event_id, relay_url);
|
||||||
|
} else {
|
||||||
|
// Keep track of events sent by Coop
|
||||||
|
tracker.sent_ids.insert(event_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if event is published by current user
|
||||||
|
pub async fn is_self_authored(client: &Client, event: &Event) -> bool {
|
||||||
|
if let Ok(signer) = client.signer().await {
|
||||||
|
if let Ok(public_key) = signer.get_public_key().await {
|
||||||
|
return public_key == event.pubkey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get event that match the given kind for a given author
|
||||||
|
async fn get(client: &Client, urls: &[RelayUrl], author: PublicKey, kind: Kind) {
|
||||||
|
// Skip if no relays are provided
|
||||||
|
if urls.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure relay connections
|
||||||
|
for url in urls.iter() {
|
||||||
|
client.add_relay(url).await.ok();
|
||||||
|
client.connect_relay(url).await.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
|
||||||
|
let filter = Filter::new().author(author).kind(kind).limit(1);
|
||||||
|
|
||||||
|
// Subscribe to filters from the user's write relays
|
||||||
|
if let Err(e) = client.subscribe_to(urls, filter, Some(opts)).await {
|
||||||
|
log::error!("Failed to subscribe: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all gift wrap events in the messaging relays for a given public key
|
||||||
|
pub async fn get_messages(client: &Client, public_key: PublicKey, urls: &[RelayUrl]) {
|
||||||
|
// Verify that there are relays provided
|
||||||
|
if urls.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure relay connection
|
||||||
|
for url in urls.iter() {
|
||||||
|
client.add_relay(url).await.ok();
|
||||||
|
client.connect_relay(url).await.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
let id = SubscriptionId::new(GIFTWRAP_SUBSCRIPTION);
|
||||||
|
let filter = Filter::new().kind(Kind::GiftWrap).pubkey(public_key);
|
||||||
|
|
||||||
|
// Unsubscribe from the previous subscription
|
||||||
|
client.unsubscribe(&id).await;
|
||||||
|
|
||||||
|
// Subscribe to filters to user's messaging relays
|
||||||
|
if let Err(e) = client.subscribe_with_id_to(urls, id, filter, None).await {
|
||||||
|
log::error!("Failed to subscribe: {}", e);
|
||||||
|
} else {
|
||||||
|
log::info!("Subscribed to gift wrap events for public key {public_key}",);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get metadata for a list of public keys
|
||||||
|
async fn get_metadata_for_list(client: &Client, pubkeys: Vec<PublicKey>) -> Result<(), Error> {
|
||||||
|
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
|
||||||
|
let kinds = vec![Kind::Metadata, Kind::ContactList];
|
||||||
|
|
||||||
|
// Return if the list is empty
|
||||||
|
if pubkeys.is_empty() {
|
||||||
|
return Err(anyhow!("You need at least one public key".to_string(),));
|
||||||
|
}
|
||||||
|
|
||||||
|
let filter = Filter::new()
|
||||||
|
.limit(pubkeys.len() * kinds.len())
|
||||||
|
.authors(pubkeys)
|
||||||
|
.kinds(kinds);
|
||||||
|
|
||||||
|
// Subscribe to filters to the bootstrap relays
|
||||||
|
client
|
||||||
|
.subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts))
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn extract_read_relays(event: &Event) -> Vec<RelayUrl> {
|
||||||
|
nip65::extract_relay_list(event)
|
||||||
|
.filter_map(|(url, metadata)| {
|
||||||
|
if metadata.is_none() || metadata == &Some(RelayMetadata::Read) {
|
||||||
|
Some(url.to_owned())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.take(3)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn extract_write_relays(event: &Event) -> Vec<RelayUrl> {
|
||||||
|
nip65::extract_relay_list(event)
|
||||||
|
.filter_map(|(url, metadata)| {
|
||||||
|
if metadata.is_none() || metadata == &Some(RelayMetadata::Write) {
|
||||||
|
Some(url.to_owned())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.take(3)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract an encryption keys announcement from an event.
|
||||||
|
pub fn extract_announcement(event: &Event) -> Result<Announcement, Error> {
|
||||||
|
let public_key = event
|
||||||
|
.tags
|
||||||
|
.iter()
|
||||||
|
.find(|tag| tag.kind().as_str() == "n" || tag.kind().as_str() == "pubkey")
|
||||||
|
.and_then(|tag| tag.content())
|
||||||
|
.and_then(|c| PublicKey::parse(c).ok())
|
||||||
|
.context("Cannot parse public key from the event's tags")?;
|
||||||
|
|
||||||
|
let client_name = event
|
||||||
|
.tags
|
||||||
|
.find(TagKind::Client)
|
||||||
|
.and_then(|tag| tag.content())
|
||||||
|
.map(|c| c.to_string());
|
||||||
|
|
||||||
|
Ok(Announcement::new(event.id, client_name, public_key))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract an encryption keys response from an event.
|
||||||
|
pub async fn extract_response(client: &Client, event: &Event) -> Result<Response, Error> {
|
||||||
|
let signer = client.signer().await?;
|
||||||
|
let public_key = signer.get_public_key().await?;
|
||||||
|
|
||||||
|
if event.pubkey != public_key {
|
||||||
|
return Err(anyhow!("Event does not belong to current user"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let client_pubkey = event
|
||||||
|
.tags
|
||||||
|
.find(TagKind::custom("P"))
|
||||||
|
.and_then(|tag| tag.content())
|
||||||
|
.and_then(|c| PublicKey::parse(c).ok())
|
||||||
|
.context("Cannot parse public key from the event's tags")?;
|
||||||
|
|
||||||
|
Ok(Response::new(event.content.clone(), client_pubkey))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a reference to the nostr client.
|
||||||
|
pub fn client(&self) -> Client {
|
||||||
|
self.client.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a reference to the event tracker.
|
||||||
|
pub fn tracker(&self) -> Arc<RwLock<EventTracker>> {
|
||||||
|
Arc::clone(&self.tracker)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a reference to the cache manager.
|
||||||
|
pub fn gossip(&self) -> Arc<RwLock<Gossip>> {
|
||||||
|
Arc::clone(&self.gossip)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
|
use gpui::SharedString;
|
||||||
|
use nostr_sdk::prelude::*;
|
||||||
|
|
||||||
|
use crate::NostrRegistry;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
|
pub struct Announcement {
|
||||||
|
id: EventId,
|
||||||
|
public_key: PublicKey,
|
||||||
|
client_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Announcement {
|
||||||
|
pub fn new(id: EventId, client_name: Option<String>, public_key: PublicKey) -> Self {
|
||||||
|
Self {
|
||||||
|
id,
|
||||||
|
client_name,
|
||||||
|
public_key,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn id(&self) -> EventId {
|
||||||
|
self.id
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn public_key(&self) -> PublicKey {
|
||||||
|
self.public_key
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn client_name(&self) -> SharedString {
|
||||||
|
self.client_name
|
||||||
|
.as_ref()
|
||||||
|
.map(SharedString::from)
|
||||||
|
.unwrap_or(SharedString::from("Unknown"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
|
pub struct Response {
|
||||||
|
payload: String,
|
||||||
|
public_key: PublicKey,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Response {
|
||||||
|
pub fn new(payload: String, public_key: PublicKey) -> Self {
|
||||||
|
Self {
|
||||||
|
payload,
|
||||||
|
public_key,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn public_key(&self) -> PublicKey {
|
||||||
|
self.public_key
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn payload(&self) -> &str {
|
||||||
|
self.payload.as_str()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct Gossip {
|
||||||
|
/// Gossip relays for each public key
|
||||||
|
relays: HashMap<PublicKey, HashSet<(RelayUrl, Option<RelayMetadata>)>>,
|
||||||
|
|
||||||
|
/// Messaging relays for each public key
|
||||||
|
messaging_relays: HashMap<PublicKey, HashSet<RelayUrl>>,
|
||||||
|
|
||||||
|
/// Encryption announcement for each public key
|
||||||
|
announcements: HashMap<PublicKey, Option<Announcement>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Gossip {
|
||||||
|
/// Get inbox relays for a public key
|
||||||
|
pub fn inbox_relays(&self, public_key: &PublicKey) -> Vec<RelayUrl> {
|
||||||
|
self.relays
|
||||||
|
.get(public_key)
|
||||||
|
.map(|relays| {
|
||||||
|
relays
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(url, metadata)| {
|
||||||
|
if metadata.is_none() || metadata == &Some(RelayMetadata::Read) {
|
||||||
|
Some(url.to_owned())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get outbox relays for a public key
|
||||||
|
pub fn outbox_relays(&self, public_key: &PublicKey) -> Vec<RelayUrl> {
|
||||||
|
self.relays
|
||||||
|
.get(public_key)
|
||||||
|
.map(|relays| {
|
||||||
|
relays
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(url, metadata)| {
|
||||||
|
if metadata.is_none() || metadata == &Some(RelayMetadata::Write) {
|
||||||
|
Some(url.to_owned())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert gossip relays for a public key
|
||||||
|
pub fn insert_relays(&mut self, event: &Event) {
|
||||||
|
self.relays.entry(event.pubkey).or_default().extend(
|
||||||
|
event
|
||||||
|
.tags
|
||||||
|
.iter()
|
||||||
|
.filter_map(|tag| {
|
||||||
|
if let Some(TagStandard::RelayMetadata {
|
||||||
|
relay_url,
|
||||||
|
metadata,
|
||||||
|
}) = tag.clone().to_standardized()
|
||||||
|
{
|
||||||
|
Some((relay_url, metadata))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.take(3),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get messaging relays for a public key
|
||||||
|
pub fn messaging_relays(&self, public_key: &PublicKey) -> Vec<RelayUrl> {
|
||||||
|
self.messaging_relays
|
||||||
|
.get(public_key)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into_iter()
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert messaging relays for a public key
|
||||||
|
pub fn insert_messaging_relays(&mut self, event: &Event) {
|
||||||
|
self.messaging_relays
|
||||||
|
.entry(event.pubkey)
|
||||||
|
.or_default()
|
||||||
|
.extend(
|
||||||
|
event
|
||||||
|
.tags
|
||||||
|
.iter()
|
||||||
|
.filter_map(|tag| {
|
||||||
|
if let Some(TagStandard::Relay(url)) = tag.as_standardized() {
|
||||||
|
Some(url.to_owned())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.take(3),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ensure connections for the given relay list
|
||||||
|
pub async fn ensure_connections(&self, client: &Client, urls: &[RelayUrl]) {
|
||||||
|
for url in urls {
|
||||||
|
client.add_relay(url).await.ok();
|
||||||
|
client.connect_relay(url).await.ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get announcement for a public key
|
||||||
|
pub fn announcement(&self, public_key: &PublicKey) -> Option<Announcement> {
|
||||||
|
self.announcements
|
||||||
|
.get(public_key)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert announcement for a public key
|
||||||
|
pub fn insert_announcement(&mut self, event: &Event) {
|
||||||
|
let announcement = NostrRegistry::extract_announcement(event).ok();
|
||||||
|
|
||||||
|
self.announcements
|
||||||
|
.entry(event.pubkey)
|
||||||
|
.or_insert(announcement);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
|
use nostr_sdk::prelude::*;
|
||||||
|
|
||||||
|
static INITIALIZED_AT: OnceLock<Timestamp> = OnceLock::new();
|
||||||
|
|
||||||
|
pub fn initialized_at() -> &'static Timestamp {
|
||||||
|
INITIALIZED_AT.get_or_init(Timestamp::now)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct EventTracker {
|
||||||
|
/// Tracking events that have been resent by Coop in the current session
|
||||||
|
pub resent_ids: Vec<Output<EventId>>,
|
||||||
|
|
||||||
|
/// Temporarily store events that need to be resent later
|
||||||
|
pub resend_queue: HashMap<EventId, RelayUrl>,
|
||||||
|
|
||||||
|
/// Tracking events sent by Coop in the current session
|
||||||
|
pub sent_ids: HashSet<EventId>,
|
||||||
|
|
||||||
|
/// Tracking events seen on which relays in the current session
|
||||||
|
pub seen_on_relays: HashMap<EventId, HashSet<RelayUrl>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EventTracker {
|
||||||
|
pub fn resent_ids(&self) -> &Vec<Output<EventId>> {
|
||||||
|
&self.resent_ids
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resend_queue(&self) -> &HashMap<EventId, RelayUrl> {
|
||||||
|
&self.resend_queue
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sent_ids(&self) -> &HashSet<EventId> {
|
||||||
|
&self.sent_ids
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn seen_on_relays(&self) -> &HashMap<EventId, HashSet<RelayUrl>> {
|
||||||
|
&self.seen_on_relays
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -89,11 +89,15 @@ impl Render for TitleBar {
|
|||||||
.h(height)
|
.h(height)
|
||||||
.map(|this| {
|
.map(|this| {
|
||||||
if window.is_fullscreen() {
|
if window.is_fullscreen() {
|
||||||
this.pl_2()
|
this.px_2()
|
||||||
} else if cx.theme().platform_kind.is_mac() {
|
} else if cx.theme().platform_kind.is_mac() {
|
||||||
this.pl(px(platforms::mac::TRAFFIC_LIGHT_PADDING))
|
this.pl(px(platforms::mac::TRAFFIC_LIGHT_PADDING))
|
||||||
|
.pr_2()
|
||||||
|
.when(children.len() <= 1, |this| {
|
||||||
|
this.pr(px(platforms::mac::TRAFFIC_LIGHT_PADDING))
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
this.pl_2()
|
this.px_2()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.map(|this| match decorations {
|
.map(|this| match decorations {
|
||||||
|
|||||||
+3
-10
@@ -7,11 +7,7 @@ publish.workspace = true
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
common = { path = "../common" }
|
common = { path = "../common" }
|
||||||
theme = { path = "../theme" }
|
theme = { path = "../theme" }
|
||||||
registry = { path = "../registry" }
|
|
||||||
|
|
||||||
rust-i18n.workspace = true
|
|
||||||
i18n.workspace = true
|
|
||||||
nostr-sdk.workspace = true
|
|
||||||
gpui.workspace = true
|
gpui.workspace = true
|
||||||
smol.workspace = true
|
smol.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
@@ -20,14 +16,11 @@ smallvec.workspace = true
|
|||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
itertools.workspace = true
|
itertools.workspace = true
|
||||||
log.workspace = true
|
log.workspace = true
|
||||||
emojis.workspace = true
|
|
||||||
|
|
||||||
regex = "1"
|
|
||||||
unicode-segmentation = "1.12.0"
|
unicode-segmentation = "1.12.0"
|
||||||
uuid = "1.10"
|
uuid = "1.10"
|
||||||
once_cell = "1.19.0"
|
regex = "1"
|
||||||
image = "0.25.1"
|
image = "0.25.1"
|
||||||
linkify = "0.10.0"
|
|
||||||
lsp-types = "0.97.0"
|
lsp-types = "0.97.0"
|
||||||
rope = { git = "https://github.com/zed-industries/zed.git" }
|
rope = { git = "https://github.com/zed-industries/zed" }
|
||||||
sum_tree = { git = "https://github.com/zed-industries/zed.git" }
|
sum_tree = { git = "https://github.com/zed-industries/zed" }
|
||||||
|
|||||||
@@ -1,17 +1,6 @@
|
|||||||
use gpui::{actions, Action};
|
use gpui::{actions, Action};
|
||||||
use nostr_sdk::prelude::PublicKey;
|
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
/// Define a open public key action
|
|
||||||
#[derive(Action, Clone, PartialEq, Eq, Deserialize, Debug)]
|
|
||||||
#[action(namespace = pubkey, no_json)]
|
|
||||||
pub struct OpenPublicKey(pub PublicKey);
|
|
||||||
|
|
||||||
/// Define a copy inline public key action
|
|
||||||
#[derive(Action, Clone, PartialEq, Eq, Deserialize, Debug)]
|
|
||||||
#[action(namespace = pubkey, no_json)]
|
|
||||||
pub struct CopyPublicKey(pub PublicKey);
|
|
||||||
|
|
||||||
/// Define a custom confirm action
|
/// Define a custom confirm action
|
||||||
#[derive(Clone, Action, PartialEq, Eq, Deserialize)]
|
#[derive(Clone, Action, PartialEq, Eq, Deserialize)]
|
||||||
#[action(namespace = list, no_json)]
|
#[action(namespace = list, no_json)]
|
||||||
@@ -20,14 +9,4 @@ pub struct Confirm {
|
|||||||
pub secondary: bool,
|
pub secondary: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
actions!(
|
actions!(ui, [Cancel, SelectUp, SelectDown, SelectLeft, SelectRight]);
|
||||||
list,
|
|
||||||
[
|
|
||||||
/// Close current list
|
|
||||||
Cancel,
|
|
||||||
/// Select the next item in lists
|
|
||||||
SelectPrev,
|
|
||||||
/// Select the previous item in list
|
|
||||||
SelectNext
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|||||||
+123
-75
@@ -1,7 +1,9 @@
|
|||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
use gpui::prelude::FluentBuilder as _;
|
use gpui::prelude::FluentBuilder as _;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
div, relative, AnyElement, App, ClickEvent, Div, ElementId, Hsla, InteractiveElement,
|
div, relative, AnyElement, App, ClickEvent, Div, ElementId, Hsla, InteractiveElement,
|
||||||
IntoElement, MouseButton, ParentElement, RenderOnce, SharedString, Stateful,
|
IntoElement, ParentElement, RenderOnce, SharedString, Stateful,
|
||||||
StatefulInteractiveElement as _, StyleRefinement, Styled, Window,
|
StatefulInteractiveElement as _, StyleRefinement, Styled, Window,
|
||||||
};
|
};
|
||||||
use theme::ActiveTheme;
|
use theme::ActiveTheme;
|
||||||
@@ -94,28 +96,25 @@ impl ButtonCustomVariant {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The variant of the Button.
|
/// The variant of the Button.
|
||||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
#[derive(Clone, Copy, PartialEq, Eq, Default)]
|
||||||
pub enum ButtonVariant {
|
pub enum ButtonVariant {
|
||||||
|
#[default]
|
||||||
Primary,
|
Primary,
|
||||||
Secondary,
|
Secondary,
|
||||||
Danger,
|
Danger,
|
||||||
Warning,
|
Warning,
|
||||||
Ghost { alt: bool },
|
Ghost {
|
||||||
|
alt: bool,
|
||||||
|
},
|
||||||
Transparent,
|
Transparent,
|
||||||
Custom(ButtonCustomVariant),
|
Custom(ButtonCustomVariant),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ButtonVariant {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::Primary
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type OnClick = Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>;
|
|
||||||
|
|
||||||
/// A Button element.
|
/// A Button element.
|
||||||
#[derive(IntoElement)]
|
#[derive(IntoElement)]
|
||||||
|
#[allow(clippy::type_complexity)]
|
||||||
pub struct Button {
|
pub struct Button {
|
||||||
|
id: ElementId,
|
||||||
base: Stateful<Div>,
|
base: Stateful<Div>,
|
||||||
style: StyleRefinement,
|
style: StyleRefinement,
|
||||||
|
|
||||||
@@ -136,10 +135,13 @@ pub struct Button {
|
|||||||
loading: bool,
|
loading: bool,
|
||||||
loading_icon: Option<Icon>,
|
loading_icon: Option<Icon>,
|
||||||
|
|
||||||
on_click: OnClick,
|
on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
|
||||||
|
on_hover: Option<Rc<dyn Fn(&bool, &mut Window, &mut App)>>,
|
||||||
|
|
||||||
|
tab_index: isize,
|
||||||
|
tab_stop: bool,
|
||||||
|
|
||||||
pub(crate) selected: bool,
|
pub(crate) selected: bool,
|
||||||
pub(crate) stop_propagation: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<Button> for AnyElement {
|
impl From<Button> for AnyElement {
|
||||||
@@ -150,8 +152,11 @@ impl From<Button> for AnyElement {
|
|||||||
|
|
||||||
impl Button {
|
impl Button {
|
||||||
pub fn new(id: impl Into<ElementId>) -> Self {
|
pub fn new(id: impl Into<ElementId>) -> Self {
|
||||||
|
let id = id.into();
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
base: div().id(id.into()).flex_shrink_0(),
|
id: id.clone(),
|
||||||
|
base: div().flex_shrink_0().id(id),
|
||||||
style: StyleRefinement::default(),
|
style: StyleRefinement::default(),
|
||||||
icon: None,
|
icon: None,
|
||||||
label: None,
|
label: None,
|
||||||
@@ -162,13 +167,15 @@ impl Button {
|
|||||||
size: Size::Medium,
|
size: Size::Medium,
|
||||||
tooltip: None,
|
tooltip: None,
|
||||||
on_click: None,
|
on_click: None,
|
||||||
stop_propagation: true,
|
on_hover: None,
|
||||||
loading: false,
|
loading: false,
|
||||||
reverse: false,
|
reverse: false,
|
||||||
bold: false,
|
bold: false,
|
||||||
cta: false,
|
cta: false,
|
||||||
children: Vec::new(),
|
children: Vec::new(),
|
||||||
loading_icon: None,
|
loading_icon: None,
|
||||||
|
tab_index: 0,
|
||||||
|
tab_stop: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,26 +227,52 @@ impl Button {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the stop propagation of the button.
|
|
||||||
pub fn stop_propagation(mut self, val: bool) -> Self {
|
|
||||||
self.stop_propagation = val;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set the loading icon of the button.
|
/// Set the loading icon of the button.
|
||||||
pub fn loading_icon(mut self, icon: impl Into<Icon>) -> Self {
|
pub fn loading_icon(mut self, icon: impl Into<Icon>) -> Self {
|
||||||
self.loading_icon = Some(icon.into());
|
self.loading_icon = Some(icon.into());
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the click handler of the button.
|
/// Add click handler.
|
||||||
pub fn on_click<C>(mut self, handler: C) -> Self
|
pub fn on_click(
|
||||||
where
|
mut self,
|
||||||
C: Fn(&ClickEvent, &mut Window, &mut App) + 'static,
|
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
|
||||||
{
|
) -> Self {
|
||||||
self.on_click = Some(Box::new(handler));
|
self.on_click = Some(Rc::new(handler));
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Add hover handler, the bool parameter indicates whether the mouse is hovering.
|
||||||
|
pub fn on_hover(mut self, handler: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
|
||||||
|
self.on_hover = Some(Rc::new(handler));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the tab index of the button, it will be used to focus the button by tab key.
|
||||||
|
///
|
||||||
|
/// Default is 0.
|
||||||
|
pub fn tab_index(mut self, tab_index: isize) -> Self {
|
||||||
|
self.tab_index = tab_index;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the tab stop of the button, if true, the button will be focusable by tab key.
|
||||||
|
///
|
||||||
|
/// Default is true.
|
||||||
|
pub fn tab_stop(mut self, tab_stop: bool) -> Self {
|
||||||
|
self.tab_stop = tab_stop;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn clickable(&self) -> bool {
|
||||||
|
!(self.disabled || self.loading) && self.on_click.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn hoverable(&self) -> bool {
|
||||||
|
!(self.disabled || self.loading) && self.on_hover.is_some()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Disableable for Button {
|
impl Disableable for Button {
|
||||||
@@ -295,20 +328,35 @@ impl InteractiveElement for Button {
|
|||||||
impl RenderOnce for Button {
|
impl RenderOnce for Button {
|
||||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||||
let style: ButtonVariant = self.variant;
|
let style: ButtonVariant = self.variant;
|
||||||
let normal_style = style.normal(window, cx);
|
let clickable = self.clickable();
|
||||||
|
let hoverable = self.hoverable();
|
||||||
|
let normal_style = style.normal(cx);
|
||||||
let icon_size = match self.size {
|
let icon_size = match self.size {
|
||||||
Size::Size(v) => Size::Size(v * 0.75),
|
Size::Size(v) => Size::Size(v * 0.75),
|
||||||
Size::Large => Size::Medium,
|
Size::Large => Size::Medium,
|
||||||
_ => self.size,
|
_ => self.size,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let focus_handle = window
|
||||||
|
.use_keyed_state(self.id.clone(), cx, |_, cx| cx.focus_handle())
|
||||||
|
.read(cx)
|
||||||
|
.clone();
|
||||||
|
|
||||||
self.base
|
self.base
|
||||||
|
.when(!self.disabled, |this| {
|
||||||
|
this.track_focus(
|
||||||
|
&focus_handle
|
||||||
|
.tab_index(self.tab_index)
|
||||||
|
.tab_stop(self.tab_stop),
|
||||||
|
)
|
||||||
|
})
|
||||||
.flex_shrink_0()
|
.flex_shrink_0()
|
||||||
.flex()
|
.flex()
|
||||||
.items_center()
|
.items_center()
|
||||||
.justify_center()
|
.justify_center()
|
||||||
.cursor_default()
|
.cursor_default()
|
||||||
.overflow_hidden()
|
.overflow_hidden()
|
||||||
|
.refine_style(&self.style)
|
||||||
.map(|this| match self.rounded {
|
.map(|this| match self.rounded {
|
||||||
false => this.rounded(cx.theme().radius),
|
false => this.rounded(cx.theme().radius),
|
||||||
true => this.rounded_full(),
|
true => this.rounded_full(),
|
||||||
@@ -386,30 +434,20 @@ impl RenderOnce for Button {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.text_color(normal_style.fg)
|
.on_mouse_down(gpui::MouseButton::Left, |_, window, _| {
|
||||||
.when(!self.disabled && !self.selected, |this| {
|
// Avoid focus on mouse down.
|
||||||
this.bg(normal_style.bg)
|
window.prevent_default();
|
||||||
.hover(|this| {
|
|
||||||
let hover_style = style.hovered(window, cx);
|
|
||||||
this.bg(hover_style.bg).text_color(hover_style.fg)
|
|
||||||
})
|
})
|
||||||
.active(|this| {
|
.when_some(self.on_click.filter(|_| clickable), |this, on_click| {
|
||||||
let active_style = style.active(window, cx);
|
this.on_click(move |event, window, cx| {
|
||||||
this.bg(active_style.bg).text_color(active_style.fg)
|
(on_click)(event, window, cx);
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.when(self.selected, |this| {
|
.when_some(self.on_hover.filter(|_| hoverable), |this, on_hover| {
|
||||||
let selected_style = style.selected(window, cx);
|
this.on_hover(move |hovered, window, cx| {
|
||||||
this.bg(selected_style.bg).text_color(selected_style.fg)
|
(on_hover)(hovered, window, cx);
|
||||||
})
|
})
|
||||||
.when(self.disabled, |this| {
|
|
||||||
let disabled_style = style.disabled(window, cx);
|
|
||||||
this.cursor_not_allowed()
|
|
||||||
.bg(disabled_style.bg)
|
|
||||||
.text_color(disabled_style.fg)
|
|
||||||
.shadow_none()
|
|
||||||
})
|
})
|
||||||
.refine_style(&self.style)
|
|
||||||
.child({
|
.child({
|
||||||
h_flex()
|
h_flex()
|
||||||
.id("label")
|
.id("label")
|
||||||
@@ -442,25 +480,35 @@ impl RenderOnce for Button {
|
|||||||
})
|
})
|
||||||
.children(self.children)
|
.children(self.children)
|
||||||
})
|
})
|
||||||
.when(self.loading, |this| this.bg(normal_style.bg.opacity(0.8)))
|
.text_color(normal_style.fg)
|
||||||
|
.when(!self.disabled && !self.selected, |this| {
|
||||||
|
this.bg(normal_style.bg)
|
||||||
|
.hover(|this| {
|
||||||
|
let hover_style = style.hovered(cx);
|
||||||
|
this.bg(hover_style.bg).text_color(hover_style.fg)
|
||||||
|
})
|
||||||
|
.active(|this| {
|
||||||
|
let active_style = style.active(cx);
|
||||||
|
this.bg(active_style.bg).text_color(active_style.fg)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.when(self.selected, |this| {
|
||||||
|
let selected_style = style.selected(cx);
|
||||||
|
this.bg(selected_style.bg).text_color(selected_style.fg)
|
||||||
|
})
|
||||||
|
.when(self.disabled, |this| {
|
||||||
|
let disabled_style = style.disabled(cx);
|
||||||
|
this.cursor_not_allowed()
|
||||||
|
.bg(disabled_style.bg)
|
||||||
|
.text_color(disabled_style.fg)
|
||||||
|
})
|
||||||
|
.when(self.loading && !self.disabled, |this| {
|
||||||
|
this.bg(normal_style.bg.opacity(0.8))
|
||||||
|
.text_color(normal_style.fg.opacity(0.8))
|
||||||
|
})
|
||||||
.when_some(self.tooltip.clone(), |this, tooltip| {
|
.when_some(self.tooltip.clone(), |this, tooltip| {
|
||||||
this.tooltip(move |window, cx| Tooltip::new(tooltip.clone(), window, cx).into())
|
this.tooltip(move |window, cx| Tooltip::new(tooltip.clone(), window, cx).into())
|
||||||
})
|
})
|
||||||
.when_some(
|
|
||||||
self.on_click.filter(|_| !self.disabled && !self.loading),
|
|
||||||
|this, on_click| {
|
|
||||||
let stop_propagation = self.stop_propagation;
|
|
||||||
this.on_mouse_down(MouseButton::Left, move |_, window, cx| {
|
|
||||||
window.prevent_default();
|
|
||||||
if stop_propagation {
|
|
||||||
cx.stop_propagation();
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.on_click(move |event, window, cx| {
|
|
||||||
(on_click)(event, window, cx);
|
|
||||||
})
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -470,14 +518,14 @@ struct ButtonVariantStyle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ButtonVariant {
|
impl ButtonVariant {
|
||||||
fn normal(&self, window: &Window, cx: &App) -> ButtonVariantStyle {
|
fn normal(&self, cx: &App) -> ButtonVariantStyle {
|
||||||
let bg = self.bg_color(window, cx);
|
let bg = self.bg_color(cx);
|
||||||
let fg = self.text_color(window, cx);
|
let fg = self.text_color(cx);
|
||||||
|
|
||||||
ButtonVariantStyle { bg, fg }
|
ButtonVariantStyle { bg, fg }
|
||||||
}
|
}
|
||||||
|
|
||||||
fn bg_color(&self, _window: &Window, cx: &App) -> Hsla {
|
fn bg_color(&self, cx: &App) -> Hsla {
|
||||||
match self {
|
match self {
|
||||||
ButtonVariant::Primary => cx.theme().element_background,
|
ButtonVariant::Primary => cx.theme().element_background,
|
||||||
ButtonVariant::Secondary => cx.theme().elevated_surface_background,
|
ButtonVariant::Secondary => cx.theme().elevated_surface_background,
|
||||||
@@ -495,7 +543,7 @@ impl ButtonVariant {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn text_color(&self, _window: &Window, cx: &App) -> Hsla {
|
fn text_color(&self, cx: &App) -> Hsla {
|
||||||
match self {
|
match self {
|
||||||
ButtonVariant::Primary => cx.theme().element_foreground,
|
ButtonVariant::Primary => cx.theme().element_foreground,
|
||||||
ButtonVariant::Secondary => cx.theme().text_muted,
|
ButtonVariant::Secondary => cx.theme().text_muted,
|
||||||
@@ -513,7 +561,7 @@ impl ButtonVariant {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn hovered(&self, window: &Window, cx: &App) -> ButtonVariantStyle {
|
fn hovered(&self, cx: &App) -> ButtonVariantStyle {
|
||||||
let bg = match self {
|
let bg = match self {
|
||||||
ButtonVariant::Primary => cx.theme().element_hover,
|
ButtonVariant::Primary => cx.theme().element_hover,
|
||||||
ButtonVariant::Secondary => cx.theme().secondary_hover,
|
ButtonVariant::Secondary => cx.theme().secondary_hover,
|
||||||
@@ -528,13 +576,13 @@ impl ButtonVariant {
|
|||||||
ButtonVariant::Secondary => cx.theme().secondary_foreground,
|
ButtonVariant::Secondary => cx.theme().secondary_foreground,
|
||||||
ButtonVariant::Ghost { .. } => cx.theme().text,
|
ButtonVariant::Ghost { .. } => cx.theme().text,
|
||||||
ButtonVariant::Transparent => cx.theme().text_placeholder,
|
ButtonVariant::Transparent => cx.theme().text_placeholder,
|
||||||
_ => self.text_color(window, cx),
|
_ => self.text_color(cx),
|
||||||
};
|
};
|
||||||
|
|
||||||
ButtonVariantStyle { bg, fg }
|
ButtonVariantStyle { bg, fg }
|
||||||
}
|
}
|
||||||
|
|
||||||
fn active(&self, window: &Window, cx: &App) -> ButtonVariantStyle {
|
fn active(&self, cx: &App) -> ButtonVariantStyle {
|
||||||
let bg = match self {
|
let bg = match self {
|
||||||
ButtonVariant::Primary => cx.theme().element_active,
|
ButtonVariant::Primary => cx.theme().element_active,
|
||||||
ButtonVariant::Secondary => cx.theme().secondary_active,
|
ButtonVariant::Secondary => cx.theme().secondary_active,
|
||||||
@@ -548,13 +596,13 @@ impl ButtonVariant {
|
|||||||
let fg = match self {
|
let fg = match self {
|
||||||
ButtonVariant::Secondary => cx.theme().secondary_foreground,
|
ButtonVariant::Secondary => cx.theme().secondary_foreground,
|
||||||
ButtonVariant::Transparent => cx.theme().text_placeholder,
|
ButtonVariant::Transparent => cx.theme().text_placeholder,
|
||||||
_ => self.text_color(window, cx),
|
_ => self.text_color(cx),
|
||||||
};
|
};
|
||||||
|
|
||||||
ButtonVariantStyle { bg, fg }
|
ButtonVariantStyle { bg, fg }
|
||||||
}
|
}
|
||||||
|
|
||||||
fn selected(&self, window: &Window, cx: &App) -> ButtonVariantStyle {
|
fn selected(&self, cx: &App) -> ButtonVariantStyle {
|
||||||
let bg = match self {
|
let bg = match self {
|
||||||
ButtonVariant::Primary => cx.theme().element_selected,
|
ButtonVariant::Primary => cx.theme().element_selected,
|
||||||
ButtonVariant::Secondary => cx.theme().secondary_selected,
|
ButtonVariant::Secondary => cx.theme().secondary_selected,
|
||||||
@@ -568,13 +616,13 @@ impl ButtonVariant {
|
|||||||
let fg = match self {
|
let fg = match self {
|
||||||
ButtonVariant::Secondary => cx.theme().secondary_foreground,
|
ButtonVariant::Secondary => cx.theme().secondary_foreground,
|
||||||
ButtonVariant::Transparent => cx.theme().text_placeholder,
|
ButtonVariant::Transparent => cx.theme().text_placeholder,
|
||||||
_ => self.text_color(window, cx),
|
_ => self.text_color(cx),
|
||||||
};
|
};
|
||||||
|
|
||||||
ButtonVariantStyle { bg, fg }
|
ButtonVariantStyle { bg, fg }
|
||||||
}
|
}
|
||||||
|
|
||||||
fn disabled(&self, _window: &Window, cx: &App) -> ButtonVariantStyle {
|
fn disabled(&self, cx: &App) -> ButtonVariantStyle {
|
||||||
let bg = match self {
|
let bg = match self {
|
||||||
ButtonVariant::Danger => cx.theme().danger_disabled,
|
ButtonVariant::Danger => cx.theme().danger_disabled,
|
||||||
ButtonVariant::Warning => cx.theme().warning_disabled,
|
ButtonVariant::Warning => cx.theme().warning_disabled,
|
||||||
|
|||||||
@@ -299,8 +299,8 @@ impl Dock {
|
|||||||
.read(cx);
|
.read(cx);
|
||||||
|
|
||||||
let area_bounds = dock_area.bounds;
|
let area_bounds = dock_area.bounds;
|
||||||
let mut left_dock_size = Pixels(0.0);
|
let mut left_dock_size = px(0.0);
|
||||||
let mut right_dock_size = Pixels(0.0);
|
let mut right_dock_size = px(0.0);
|
||||||
|
|
||||||
// Get the size of the left dock if it's open and not the current dock
|
// Get the size of the left dock if it's open and not the current dock
|
||||||
if let Some(left_dock) = &dock_area.left_dock {
|
if let Some(left_dock) = &dock_area.left_dock {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use gpui::{
|
|||||||
};
|
};
|
||||||
use theme::ActiveTheme;
|
use theme::ActiveTheme;
|
||||||
|
|
||||||
use crate::actions::{Cancel, Confirm, SelectNext, SelectPrev};
|
use crate::actions::{Cancel, Confirm, SelectDown, SelectUp};
|
||||||
use crate::input::clear_button::clear_button;
|
use crate::input::clear_button::clear_button;
|
||||||
use crate::list::{List, ListDelegate, ListItem};
|
use crate::list::{List, ListDelegate, ListItem};
|
||||||
use crate::{h_flex, v_flex, Disableable as _, Icon, IconName, Sizable, Size, StyleSized};
|
use crate::{h_flex, v_flex, Disableable as _, Icon, IconName, Sizable, Size, StyleSized};
|
||||||
@@ -26,8 +26,8 @@ pub enum ListEvent {
|
|||||||
|
|
||||||
pub fn init(cx: &mut App) {
|
pub fn init(cx: &mut App) {
|
||||||
cx.bind_keys([
|
cx.bind_keys([
|
||||||
KeyBinding::new("up", SelectPrev, Some(CONTEXT)),
|
KeyBinding::new("up", SelectUp, Some(CONTEXT)),
|
||||||
KeyBinding::new("down", SelectNext, Some(CONTEXT)),
|
KeyBinding::new("down", SelectDown, Some(CONTEXT)),
|
||||||
KeyBinding::new("enter", Confirm { secondary: false }, Some(CONTEXT)),
|
KeyBinding::new("enter", Confirm { secondary: false }, Some(CONTEXT)),
|
||||||
KeyBinding::new(
|
KeyBinding::new(
|
||||||
"secondary-enter",
|
"secondary-enter",
|
||||||
@@ -440,7 +440,7 @@ where
|
|||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn up(&mut self, _: &SelectPrev, window: &mut Window, cx: &mut Context<Self>) {
|
fn up(&mut self, _: &SelectUp, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
if !self.open {
|
if !self.open {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -449,7 +449,7 @@ where
|
|||||||
cx.propagate();
|
cx.propagate();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn down(&mut self, _: &SelectNext, window: &mut Window, cx: &mut Context<Self>) {
|
fn down(&mut self, _: &SelectDown, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
if !self.open {
|
if !self.open {
|
||||||
self.open = true;
|
self.open = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ pub enum IconName {
|
|||||||
Copy,
|
Copy,
|
||||||
Edit,
|
Edit,
|
||||||
Ellipsis,
|
Ellipsis,
|
||||||
|
Encryption,
|
||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
EmojiFill,
|
EmojiFill,
|
||||||
@@ -90,6 +91,7 @@ impl IconName {
|
|||||||
Self::Edit => "icons/edit.svg",
|
Self::Edit => "icons/edit.svg",
|
||||||
Self::Ellipsis => "icons/ellipsis.svg",
|
Self::Ellipsis => "icons/ellipsis.svg",
|
||||||
Self::Eye => "icons/eye.svg",
|
Self::Eye => "icons/eye.svg",
|
||||||
|
Self::Encryption => "icons/encryption.svg",
|
||||||
Self::EmojiFill => "icons/emoji-fill.svg",
|
Self::EmojiFill => "icons/emoji-fill.svg",
|
||||||
Self::EyeOff => "icons/eye-off.svg",
|
Self::EyeOff => "icons/eye-off.svg",
|
||||||
Self::Info => "icons/info.svg",
|
Self::Info => "icons/info.svg",
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ use std::rc::Rc;
|
|||||||
|
|
||||||
use gpui::{
|
use gpui::{
|
||||||
fill, point, px, relative, size, App, Bounds, Corners, Element, ElementId, ElementInputHandler,
|
fill, point, px, relative, size, App, Bounds, Corners, Element, ElementId, ElementInputHandler,
|
||||||
Entity, GlobalElementId, Half, Hitbox, IntoElement, LayoutId, MouseButton, MouseMoveEvent,
|
Entity, GlobalElementId, Hitbox, IntoElement, LayoutId, MouseButton, MouseMoveEvent, Path,
|
||||||
Path, Pixels, Point, ShapedLine, SharedString, Size, Style, TextAlign, TextRun, UnderlineStyle,
|
Pixels, Point, ShapedLine, SharedString, Size, Style, TextAlign, TextRun, UnderlineStyle,
|
||||||
Window,
|
Window,
|
||||||
};
|
};
|
||||||
use rope::Rope;
|
use rope::Rope;
|
||||||
@@ -543,7 +543,10 @@ impl Element for TextElement {
|
|||||||
let (display_text, text_color) = if is_empty {
|
let (display_text, text_color) = if is_empty {
|
||||||
(Rope::from(placeholder.as_str()), cx.theme().text_muted)
|
(Rope::from(placeholder.as_str()), cx.theme().text_muted)
|
||||||
} else if state.masked {
|
} else if state.masked {
|
||||||
(Rope::from("*".repeat(text.chars_count())), cx.theme().text)
|
(
|
||||||
|
Rope::from("*".repeat(text.chars_count()).as_str()),
|
||||||
|
cx.theme().text,
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
(text.clone(), cx.theme().text)
|
(text.clone(), cx.theme().text)
|
||||||
};
|
};
|
||||||
@@ -642,11 +645,8 @@ impl Element for TextElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let total_wrapped_lines = state.text_wrapper.len();
|
let total_wrapped_lines = state.text_wrapper.len();
|
||||||
let empty_bottom_height = bounds
|
let empty_bottom_height = px(0.);
|
||||||
.size
|
|
||||||
.height
|
|
||||||
.half()
|
|
||||||
.max(BOTTOM_MARGIN_ROWS * line_height);
|
|
||||||
let scroll_size = size(
|
let scroll_size = size(
|
||||||
if longest_line_width + line_number_width + RIGHT_MARGIN > bounds.size.width {
|
if longest_line_width + line_number_width + RIGHT_MARGIN > bounds.size.width {
|
||||||
longest_line_width + line_number_width + RIGHT_MARGIN
|
longest_line_width + line_number_width + RIGHT_MARGIN
|
||||||
@@ -872,9 +872,7 @@ impl Element for TextElement {
|
|||||||
state.set_input_bounds(input_bounds, cx);
|
state.set_input_bounds(input_bounds, cx);
|
||||||
state.last_selected_range = Some(selected_range);
|
state.last_selected_range = Some(selected_range);
|
||||||
state.scroll_size = prepaint.scroll_size;
|
state.scroll_size = prepaint.scroll_size;
|
||||||
state
|
state.update_scroll_offset(Some(prepaint.cursor_scroll_offset), cx);
|
||||||
.scroll_handle
|
|
||||||
.set_offset(prepaint.cursor_scroll_offset);
|
|
||||||
state.deferred_scroll_offset = None;
|
state.deferred_scroll_offset = None;
|
||||||
|
|
||||||
cx.notify();
|
cx.notify();
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ pub trait RopeExt {
|
|||||||
fn offset_to_position(&self, offset: usize) -> Position;
|
fn offset_to_position(&self, offset: usize) -> Position;
|
||||||
|
|
||||||
/// Get the word byte range at the given offset (byte).
|
/// Get the word byte range at the given offset (byte).
|
||||||
|
#[allow(dead_code)]
|
||||||
fn word_range(&self, offset: usize) -> Option<Range<usize>>;
|
fn word_range(&self, offset: usize) -> Option<Range<usize>>;
|
||||||
|
|
||||||
/// Get word at the given offset (byte).
|
/// Get word at the given offset (byte).
|
||||||
|
|||||||
@@ -328,7 +328,7 @@ impl InputState {
|
|||||||
|
|
||||||
Self {
|
Self {
|
||||||
focus_handle: focus_handle.clone(),
|
focus_handle: focus_handle.clone(),
|
||||||
text: "".into(),
|
text: Rope::default(),
|
||||||
text_wrapper: TextWrapper::new(
|
text_wrapper: TextWrapper::new(
|
||||||
text_style.font(),
|
text_style.font(),
|
||||||
text_style.font_size.to_pixels(window.rem_size()),
|
text_style.font_size.to_pixels(window.rem_size()),
|
||||||
@@ -481,7 +481,7 @@ impl InputState {
|
|||||||
for (line_index, line) in last_layout.lines.iter().enumerate() {
|
for (line_index, line) in last_layout.lines.iter().enumerate() {
|
||||||
let local_offset = offset.saturating_sub(prev_lines_offset);
|
let local_offset = offset.saturating_sub(prev_lines_offset);
|
||||||
if let Some(pos) = line.position_for_index(local_offset, line_height) {
|
if let Some(pos) = line.position_for_index(local_offset, line_height) {
|
||||||
let sub_line_index = (pos.y.0 / line_height.0) as usize;
|
let sub_line_index = (pos.y.signum() / line_height.signum()) as usize;
|
||||||
let adjusted_pos = point(pos.x + last_layout.line_number_width, pos.y + y_offset);
|
let adjusted_pos = point(pos.x + last_layout.line_number_width, pos.y + y_offset);
|
||||||
return (line_index, sub_line_index, Some(adjusted_pos));
|
return (line_index, sub_line_index, Some(adjusted_pos));
|
||||||
}
|
}
|
||||||
@@ -1411,7 +1411,11 @@ impl InputState {
|
|||||||
self.update_scroll_offset(Some(self.scroll_handle.offset() + delta), cx);
|
self.update_scroll_offset(Some(self.scroll_handle.offset() + delta), cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update_scroll_offset(&mut self, offset: Option<Point<Pixels>>, cx: &mut Context<Self>) {
|
pub(super) fn update_scroll_offset(
|
||||||
|
&mut self,
|
||||||
|
offset: Option<Point<Pixels>>,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) {
|
||||||
let mut offset = offset.unwrap_or(self.scroll_handle.offset());
|
let mut offset = offset.unwrap_or(self.scroll_handle.offset());
|
||||||
|
|
||||||
let safe_y_range =
|
let safe_y_range =
|
||||||
@@ -1472,13 +1476,12 @@ impl InputState {
|
|||||||
|
|
||||||
// Check if row_offset_y is out of the viewport
|
// Check if row_offset_y is out of the viewport
|
||||||
// If row offset is not in the viewport, scroll to make it visible
|
// If row offset is not in the viewport, scroll to make it visible
|
||||||
let edge_height = 3 * line_height;
|
if row_offset_y - line_height < -scroll_offset.y {
|
||||||
if row_offset_y - edge_height < -scroll_offset.y {
|
|
||||||
// Scroll up
|
// Scroll up
|
||||||
scroll_offset.y = -row_offset_y + edge_height;
|
scroll_offset.y = -row_offset_y + line_height;
|
||||||
} else if row_offset_y + edge_height > -scroll_offset.y + bounds.size.height {
|
} else if row_offset_y + line_height > -scroll_offset.y + bounds.size.height {
|
||||||
// Scroll down
|
// Scroll down
|
||||||
scroll_offset.y = -(row_offset_y - bounds.size.height + edge_height);
|
scroll_offset.y = -(row_offset_y - bounds.size.height + line_height);
|
||||||
}
|
}
|
||||||
|
|
||||||
scroll_offset.x = scroll_offset.x.min(px(0.));
|
scroll_offset.x = scroll_offset.x.min(px(0.));
|
||||||
@@ -1656,6 +1659,7 @@ impl InputState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let index_result = rendered_line.closest_index_for_position(pos, line_height);
|
let index_result = rendered_line.closest_index_for_position(pos, line_height);
|
||||||
|
|
||||||
if let Ok(v) = index_result {
|
if let Ok(v) = index_result {
|
||||||
index += v;
|
index += v;
|
||||||
break;
|
break;
|
||||||
@@ -2164,6 +2168,7 @@ impl EntityInputHandler for InputState {
|
|||||||
self.push_history(&old_text, &range, new_text);
|
self.push_history(&old_text, &range, new_text);
|
||||||
self.text_wrapper
|
self.text_wrapper
|
||||||
.update(&self.text, &range, &Rope::from(new_text), false, cx);
|
.update(&self.text, &range, &Rope::from(new_text), false, cx);
|
||||||
|
|
||||||
if new_text.is_empty() {
|
if new_text.is_empty() {
|
||||||
// Cancel selection, when cancel IME input.
|
// Cancel selection, when cancel IME input.
|
||||||
self.selected_range = (range.start..range.start).into();
|
self.selected_range = (range.start..range.start).into();
|
||||||
@@ -2178,6 +2183,7 @@ impl EntityInputHandler for InputState {
|
|||||||
.into();
|
.into();
|
||||||
}
|
}
|
||||||
self.mode.update_auto_grow(&self.text_wrapper);
|
self.mode.update_auto_grow(&self.text_wrapper);
|
||||||
|
|
||||||
cx.emit(InputEvent::Change);
|
cx.emit(InputEvent::Change);
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -150,6 +150,7 @@ impl RenderOnce for TextInput {
|
|||||||
|
|
||||||
self.state.update(cx, |state, cx| {
|
self.state.update(cx, |state, cx| {
|
||||||
state.text_wrapper.set_font(font, font_size, cx);
|
state.text_wrapper.set_font(font, font_size, cx);
|
||||||
|
state.text_wrapper.prepare_if_need(&state.text, cx);
|
||||||
state.disabled = self.disabled;
|
state.disabled = self.disabled;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -42,14 +42,19 @@ impl LineItem {
|
|||||||
/// After use lines to calculate the scroll size of the Editor.
|
/// After use lines to calculate the scroll size of the Editor.
|
||||||
pub(super) struct TextWrapper {
|
pub(super) struct TextWrapper {
|
||||||
text: Rope,
|
text: Rope,
|
||||||
|
|
||||||
/// Total wrapped lines (Inlucde the first line), value is start and end index of the line.
|
/// Total wrapped lines (Inlucde the first line), value is start and end index of the line.
|
||||||
soft_lines: usize,
|
soft_lines: usize,
|
||||||
font: Font,
|
font: Font,
|
||||||
font_size: Pixels,
|
font_size: Pixels,
|
||||||
|
|
||||||
/// If is none, it means the text is not wrapped
|
/// If is none, it means the text is not wrapped
|
||||||
wrap_width: Option<Pixels>,
|
wrap_width: Option<Pixels>,
|
||||||
|
|
||||||
/// The lines by split \n
|
/// The lines by split \n
|
||||||
pub(super) lines: Vec<LineItem>,
|
pub(super) lines: Vec<LineItem>,
|
||||||
|
|
||||||
|
_initialized: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(unused)]
|
#[allow(unused)]
|
||||||
@@ -62,6 +67,7 @@ impl TextWrapper {
|
|||||||
wrap_width,
|
wrap_width,
|
||||||
soft_lines: 0,
|
soft_lines: 0,
|
||||||
lines: Vec::new(),
|
lines: Vec::new(),
|
||||||
|
_initialized: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,7 +92,6 @@ impl TextWrapper {
|
|||||||
if wrap_width == self.wrap_width {
|
if wrap_width == self.wrap_width {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
self.wrap_width = wrap_width;
|
self.wrap_width = wrap_width;
|
||||||
self.update_all(&self.text.clone(), true, cx);
|
self.update_all(&self.text.clone(), true, cx);
|
||||||
}
|
}
|
||||||
@@ -95,12 +100,19 @@ impl TextWrapper {
|
|||||||
if self.font.eq(&font) && self.font_size == font_size {
|
if self.font.eq(&font) && self.font_size == font_size {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
self.font = font;
|
self.font = font;
|
||||||
self.font_size = font_size;
|
self.font_size = font_size;
|
||||||
self.update_all(&self.text.clone(), true, cx);
|
self.update_all(&self.text.clone(), true, cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(super) fn prepare_if_need(&mut self, text: &Rope, cx: &mut App) {
|
||||||
|
if self._initialized {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self._initialized = true;
|
||||||
|
self.update_all(text, true, cx);
|
||||||
|
}
|
||||||
|
|
||||||
/// Update the text wrapper and recalculate the wrapped lines.
|
/// Update the text wrapper and recalculate the wrapped lines.
|
||||||
///
|
///
|
||||||
/// If the `text` is the same as the current text, do nothing.
|
/// If the `text` is the same as the current text, do nothing.
|
||||||
|
|||||||
@@ -0,0 +1,312 @@
|
|||||||
|
use gpui::{
|
||||||
|
div, relative, Action, AsKeystroke, FocusHandle, IntoElement, KeyContext, Keystroke,
|
||||||
|
ParentElement as _, RenderOnce, StyleRefinement, Styled, Window,
|
||||||
|
};
|
||||||
|
use theme::ActiveTheme;
|
||||||
|
|
||||||
|
use crate::StyledExt;
|
||||||
|
|
||||||
|
/// A key binding tag
|
||||||
|
#[derive(IntoElement, Clone, Debug)]
|
||||||
|
pub struct Kbd {
|
||||||
|
style: StyleRefinement,
|
||||||
|
stroke: Keystroke,
|
||||||
|
appearance: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Keystroke> for Kbd {
|
||||||
|
fn from(stroke: Keystroke) -> Self {
|
||||||
|
Self {
|
||||||
|
style: StyleRefinement::default(),
|
||||||
|
stroke,
|
||||||
|
appearance: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Kbd {
|
||||||
|
pub fn new(stroke: Keystroke) -> Self {
|
||||||
|
Self {
|
||||||
|
style: StyleRefinement::default(),
|
||||||
|
stroke,
|
||||||
|
appearance: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the appearance of the keybinding.
|
||||||
|
pub fn appearance(mut self, appearance: bool) -> Self {
|
||||||
|
self.appearance = appearance;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the first keybinding for the given action and context.
|
||||||
|
pub fn binding_for_action(
|
||||||
|
action: &dyn Action,
|
||||||
|
context: Option<&str>,
|
||||||
|
window: &Window,
|
||||||
|
) -> Option<Self> {
|
||||||
|
let key_context = context.and_then(|context| KeyContext::parse(context).ok());
|
||||||
|
let binding = match key_context {
|
||||||
|
Some(context) => {
|
||||||
|
window.highest_precedence_binding_for_action_in_context(action, context)
|
||||||
|
}
|
||||||
|
None => window.highest_precedence_binding_for_action(action),
|
||||||
|
}?;
|
||||||
|
|
||||||
|
binding
|
||||||
|
.keystrokes()
|
||||||
|
.first()
|
||||||
|
.map(|key| Self::new(key.as_keystroke().clone()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the first keybinding for the given action and focus handle.
|
||||||
|
pub fn binding_for_action_in(
|
||||||
|
action: &dyn Action,
|
||||||
|
focus_handle: &FocusHandle,
|
||||||
|
window: &Window,
|
||||||
|
) -> Option<Self> {
|
||||||
|
let binding = window.highest_precedence_binding_for_action_in(action, focus_handle)?;
|
||||||
|
|
||||||
|
binding
|
||||||
|
.keystrokes()
|
||||||
|
.first()
|
||||||
|
.map(|key| Self::new(key.as_keystroke().clone()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the Platform specific keybinding string by KeyStroke
|
||||||
|
///
|
||||||
|
/// macOS: https://support.apple.com/en-us/HT201236
|
||||||
|
/// Windows: https://support.microsoft.com/en-us/windows/keyboard-shortcuts-in-windows-dcc61a57-8ff0-cffe-9796-cb9706c75eec
|
||||||
|
pub fn format(key: &Keystroke) -> String {
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
const DIVIDER: &str = "";
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
const DIVIDER: &str = "+";
|
||||||
|
|
||||||
|
let mut parts = vec![];
|
||||||
|
|
||||||
|
// The key map order in macOS is: ⌃⌥⇧⌘
|
||||||
|
// And in Windows is: Ctrl+Alt+Shift+Win
|
||||||
|
|
||||||
|
if key.modifiers.control {
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
parts.push("⌃");
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
parts.push("Ctrl");
|
||||||
|
}
|
||||||
|
|
||||||
|
if key.modifiers.alt {
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
parts.push("⌥");
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
parts.push("Alt");
|
||||||
|
}
|
||||||
|
|
||||||
|
if key.modifiers.shift {
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
parts.push("⇧");
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
parts.push("Shift");
|
||||||
|
}
|
||||||
|
|
||||||
|
if key.modifiers.platform {
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
parts.push("⌘");
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
parts.push("Win");
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut keys = String::new();
|
||||||
|
let key_str = key.key.as_str();
|
||||||
|
match key_str {
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
"ctrl" => keys.push('⌃'),
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
"ctrl" => keys.push_str("Ctrl"),
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
"alt" => keys.push('⌥'),
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
"alt" => keys.push_str("Alt"),
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
"shift" => keys.push('⇧'),
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
"shift" => keys.push_str("Shift"),
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
"cmd" => keys.push('⌘'),
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
"cmd" => keys.push_str("Win"),
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
"space" => keys.push_str("Space"),
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
"backspace" => keys.push('⌫'),
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
"backspace" => keys.push_str("Backspace"),
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
"delete" => keys.push('⌫'),
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
"delete" => keys.push_str("Delete"),
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
"escape" => keys.push('⎋'),
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
"escape" => keys.push_str("Esc"),
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
"enter" => keys.push('⏎'),
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
"enter" => keys.push_str("Enter"),
|
||||||
|
"pagedown" => keys.push_str("Page Down"),
|
||||||
|
"pageup" => keys.push_str("Page Up"),
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
"left" => keys.push('←'),
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
"left" => keys.push_str("Left"),
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
"right" => keys.push('→'),
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
"right" => keys.push_str("Right"),
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
"up" => keys.push('↑'),
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
"up" => keys.push_str("Up"),
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
"down" => keys.push('↓'),
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
"down" => keys.push_str("Down"),
|
||||||
|
_ => {
|
||||||
|
if key_str.len() == 1 {
|
||||||
|
keys.push_str(&key_str.to_uppercase());
|
||||||
|
} else {
|
||||||
|
let mut chars = key_str.chars();
|
||||||
|
if let Some(first_char) = chars.next() {
|
||||||
|
keys.push_str(&format!(
|
||||||
|
"{}{}",
|
||||||
|
first_char.to_uppercase(),
|
||||||
|
chars.collect::<String>()
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
keys.push_str(key_str);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
parts.push(&keys);
|
||||||
|
parts.join(DIVIDER)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Styled for Kbd {
|
||||||
|
fn style(&mut self) -> &mut StyleRefinement {
|
||||||
|
&mut self.style
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RenderOnce for Kbd {
|
||||||
|
fn render(self, _: &mut gpui::Window, cx: &mut gpui::App) -> impl gpui::IntoElement {
|
||||||
|
if !self.appearance {
|
||||||
|
return Self::format(&self.stroke).into_any_element();
|
||||||
|
}
|
||||||
|
|
||||||
|
div()
|
||||||
|
.border_1()
|
||||||
|
.border_color(cx.theme().border)
|
||||||
|
.text_color(cx.theme().text_muted)
|
||||||
|
.bg(cx.theme().surface_background)
|
||||||
|
.py_0p5()
|
||||||
|
.px_1()
|
||||||
|
.min_w_5()
|
||||||
|
.text_center()
|
||||||
|
.rounded_sm()
|
||||||
|
.line_height(relative(1.))
|
||||||
|
.text_xs()
|
||||||
|
.whitespace_normal()
|
||||||
|
.flex_shrink_0()
|
||||||
|
.refine_style(&self.style)
|
||||||
|
.child(Self::format(&self.stroke))
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
#[test]
|
||||||
|
fn test_format() {
|
||||||
|
use gpui::Keystroke;
|
||||||
|
|
||||||
|
use super::Kbd;
|
||||||
|
|
||||||
|
if cfg!(target_os = "macos") {
|
||||||
|
assert_eq!(Kbd::format(&Keystroke::parse("cmd-a").unwrap()), "⌘A");
|
||||||
|
assert_eq!(Kbd::format(&Keystroke::parse("cmd--").unwrap()), "⌘-");
|
||||||
|
assert_eq!(Kbd::format(&Keystroke::parse("cmd-+").unwrap()), "⌘+");
|
||||||
|
assert_eq!(Kbd::format(&Keystroke::parse("cmd-enter").unwrap()), "⌘⏎");
|
||||||
|
assert_eq!(
|
||||||
|
Kbd::format(&Keystroke::parse("secondary-f12").unwrap()),
|
||||||
|
"⌘F12"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Kbd::format(&Keystroke::parse("shift-pagedown").unwrap()),
|
||||||
|
"⇧Page Down"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Kbd::format(&Keystroke::parse("shift-pageup").unwrap()),
|
||||||
|
"⇧Page Up"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Kbd::format(&Keystroke::parse("shift-space").unwrap()),
|
||||||
|
"⇧Space"
|
||||||
|
);
|
||||||
|
assert_eq!(Kbd::format(&Keystroke::parse("cmd-ctrl-a").unwrap()), "⌃⌘A");
|
||||||
|
assert_eq!(
|
||||||
|
Kbd::format(&Keystroke::parse("cmd-alt-backspace").unwrap()),
|
||||||
|
"⌥⌘⌫"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Kbd::format(&Keystroke::parse("shift-delete").unwrap()),
|
||||||
|
"⇧⌫"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Kbd::format(&Keystroke::parse("cmd-ctrl-shift-a").unwrap()),
|
||||||
|
"⌃⇧⌘A"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Kbd::format(&Keystroke::parse("cmd-ctrl-shift-alt-a").unwrap()),
|
||||||
|
"⌃⌥⇧⌘A"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
assert_eq!(Kbd::format(&Keystroke::parse("a").unwrap()), "A");
|
||||||
|
assert_eq!(Kbd::format(&Keystroke::parse("ctrl-a").unwrap()), "Ctrl+A");
|
||||||
|
assert_eq!(
|
||||||
|
Kbd::format(&Keystroke::parse("shift-space").unwrap()),
|
||||||
|
"Shift+Space"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Kbd::format(&Keystroke::parse("ctrl-alt-a").unwrap()),
|
||||||
|
"Ctrl+Alt+A"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Kbd::format(&Keystroke::parse("ctrl-alt-shift-a").unwrap()),
|
||||||
|
"Ctrl+Alt+Shift+A"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Kbd::format(&Keystroke::parse("ctrl-alt-shift-win-a").unwrap()),
|
||||||
|
"Ctrl+Alt+Shift+Win+A"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Kbd::format(&Keystroke::parse("ctrl-shift-backspace").unwrap()),
|
||||||
|
"Ctrl+Shift+Backspace"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Kbd::format(&Keystroke::parse("alt-delete").unwrap()),
|
||||||
|
"Alt+Delete"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Kbd::format(&Keystroke::parse("alt-tab").unwrap()),
|
||||||
|
"Alt+Tab"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
pub use event::InteractiveElementExt;
|
pub use event::InteractiveElementExt;
|
||||||
pub use focusable::FocusableCycle;
|
pub use focusable::FocusableCycle;
|
||||||
pub use icon::*;
|
pub use icon::*;
|
||||||
|
pub use kbd::*;
|
||||||
|
pub use menu::{context_menu, popup_menu};
|
||||||
pub use root::{ContextModal, Root};
|
pub use root::{ContextModal, Root};
|
||||||
pub use styled::*;
|
pub use styled::*;
|
||||||
pub use window_border::{window_border, WindowBorder};
|
pub use window_border::{window_border, WindowBorder};
|
||||||
@@ -12,36 +14,32 @@ pub mod animation;
|
|||||||
pub mod avatar;
|
pub mod avatar;
|
||||||
pub mod button;
|
pub mod button;
|
||||||
pub mod checkbox;
|
pub mod checkbox;
|
||||||
pub mod context_menu;
|
|
||||||
pub mod divider;
|
pub mod divider;
|
||||||
pub mod dock_area;
|
pub mod dock_area;
|
||||||
pub mod dropdown;
|
pub mod dropdown;
|
||||||
pub mod emoji_picker;
|
|
||||||
pub mod history;
|
pub mod history;
|
||||||
pub mod indicator;
|
pub mod indicator;
|
||||||
pub mod input;
|
pub mod input;
|
||||||
pub mod list;
|
pub mod list;
|
||||||
|
pub mod menu;
|
||||||
pub mod modal;
|
pub mod modal;
|
||||||
pub mod notification;
|
pub mod notification;
|
||||||
pub mod popover;
|
pub mod popover;
|
||||||
pub mod popup_menu;
|
|
||||||
pub mod resizable;
|
pub mod resizable;
|
||||||
pub mod scroll;
|
pub mod scroll;
|
||||||
pub mod skeleton;
|
pub mod skeleton;
|
||||||
pub mod switch;
|
pub mod switch;
|
||||||
pub mod tab;
|
pub mod tab;
|
||||||
pub mod text;
|
|
||||||
pub mod tooltip;
|
pub mod tooltip;
|
||||||
|
|
||||||
mod event;
|
mod event;
|
||||||
mod focusable;
|
mod focusable;
|
||||||
mod icon;
|
mod icon;
|
||||||
|
mod kbd;
|
||||||
mod root;
|
mod root;
|
||||||
mod styled;
|
mod styled;
|
||||||
mod window_border;
|
mod window_border;
|
||||||
|
|
||||||
i18n::init!();
|
|
||||||
|
|
||||||
/// Initialize the UI module.
|
/// Initialize the UI module.
|
||||||
///
|
///
|
||||||
/// This must be called before using any of the UI components.
|
/// This must be called before using any of the UI components.
|
||||||
@@ -53,5 +51,5 @@ pub fn init(cx: &mut gpui::App) {
|
|||||||
list::init(cx);
|
list::init(cx);
|
||||||
modal::init(cx);
|
modal::init(cx);
|
||||||
popover::init(cx);
|
popover::init(cx);
|
||||||
popup_menu::init(cx);
|
menu::init(cx);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ use smol::Timer;
|
|||||||
use theme::ActiveTheme;
|
use theme::ActiveTheme;
|
||||||
|
|
||||||
use super::loading::Loading;
|
use super::loading::Loading;
|
||||||
use crate::actions::{Cancel, Confirm, SelectNext, SelectPrev};
|
use crate::actions::{Cancel, Confirm, SelectDown, SelectUp};
|
||||||
use crate::input::{InputEvent, InputState, TextInput};
|
use crate::input::{InputEvent, InputState, TextInput};
|
||||||
use crate::scroll::{Scrollbar, ScrollbarState};
|
use crate::scroll::{Scrollbar, ScrollbarState};
|
||||||
use crate::{v_flex, Icon, IconName, Sizable as _, Size};
|
use crate::{v_flex, Icon, IconName, Sizable as _, Size};
|
||||||
@@ -23,8 +23,8 @@ pub fn init(cx: &mut App) {
|
|||||||
KeyBinding::new("escape", Cancel, context),
|
KeyBinding::new("escape", Cancel, context),
|
||||||
KeyBinding::new("enter", Confirm { secondary: false }, context),
|
KeyBinding::new("enter", Confirm { secondary: false }, context),
|
||||||
KeyBinding::new("secondary-enter", Confirm { secondary: true }, context),
|
KeyBinding::new("secondary-enter", Confirm { secondary: true }, context),
|
||||||
KeyBinding::new("up", SelectPrev, context),
|
KeyBinding::new("up", SelectUp, context),
|
||||||
KeyBinding::new("down", SelectNext, context),
|
KeyBinding::new("down", SelectDown, context),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -428,12 +428,7 @@ where
|
|||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn on_action_select_prev(
|
fn on_select_prev(&mut self, _: &SelectUp, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
&mut self,
|
|
||||||
_: &SelectPrev,
|
|
||||||
window: &mut Window,
|
|
||||||
cx: &mut Context<Self>,
|
|
||||||
) {
|
|
||||||
let items_count = self.delegate.items_count(cx);
|
let items_count = self.delegate.items_count(cx);
|
||||||
if items_count == 0 {
|
if items_count == 0 {
|
||||||
return;
|
return;
|
||||||
@@ -448,12 +443,7 @@ where
|
|||||||
self.select_item(selected_index, window, cx);
|
self.select_item(selected_index, window, cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn on_action_select_next(
|
fn on_select_next(&mut self, _: &SelectDown, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
&mut self,
|
|
||||||
_: &SelectNext,
|
|
||||||
window: &mut Window,
|
|
||||||
cx: &mut Context<Self>,
|
|
||||||
) {
|
|
||||||
let items_count = self.delegate.items_count(cx);
|
let items_count = self.delegate.items_count(cx);
|
||||||
if items_count == 0 {
|
if items_count == 0 {
|
||||||
return;
|
return;
|
||||||
@@ -598,8 +588,8 @@ where
|
|||||||
.when(!loading, |this| {
|
.when(!loading, |this| {
|
||||||
this.on_action(cx.listener(Self::on_action_cancel))
|
this.on_action(cx.listener(Self::on_action_cancel))
|
||||||
.on_action(cx.listener(Self::on_action_confirm))
|
.on_action(cx.listener(Self::on_action_confirm))
|
||||||
.on_action(cx.listener(Self::on_action_select_next))
|
.on_action(cx.listener(Self::on_select_next))
|
||||||
.on_action(cx.listener(Self::on_action_select_prev))
|
.on_action(cx.listener(Self::on_select_prev))
|
||||||
.map(|this| {
|
.map(|this| {
|
||||||
if let Some(view) = initial_view {
|
if let Some(view) = initial_view {
|
||||||
this.child(view)
|
this.child(view)
|
||||||
@@ -639,7 +629,7 @@ where
|
|||||||
)
|
)
|
||||||
.flex_grow()
|
.flex_grow()
|
||||||
.with_sizing_behavior(sizing_behavior)
|
.with_sizing_behavior(sizing_behavior)
|
||||||
.track_scroll(vertical_scroll_handle)
|
.track_scroll(&vertical_scroll_handle)
|
||||||
.into_any_element(),
|
.into_any_element(),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,244 @@
|
|||||||
|
use gpui::prelude::FluentBuilder;
|
||||||
|
use gpui::{
|
||||||
|
anchored, deferred, div, px, App, AppContext as _, ClickEvent, Context, DismissEvent, Entity,
|
||||||
|
Focusable, InteractiveElement as _, IntoElement, KeyBinding, OwnedMenu, ParentElement, Render,
|
||||||
|
SharedString, StatefulInteractiveElement, Styled, Subscription, Window,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::actions::{Cancel, SelectLeft, SelectRight};
|
||||||
|
use crate::button::{Button, ButtonVariants};
|
||||||
|
use crate::popup_menu::PopupMenu;
|
||||||
|
use crate::{h_flex, Selectable, Sizable};
|
||||||
|
|
||||||
|
const CONTEXT: &str = "AppMenuBar";
|
||||||
|
pub fn init(cx: &mut App) {
|
||||||
|
cx.bind_keys([
|
||||||
|
KeyBinding::new("escape", Cancel, Some(CONTEXT)),
|
||||||
|
KeyBinding::new("left", SelectLeft, Some(CONTEXT)),
|
||||||
|
KeyBinding::new("right", SelectRight, Some(CONTEXT)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The application menu bar, for Windows and Linux.
|
||||||
|
pub struct AppMenuBar {
|
||||||
|
menus: Vec<Entity<AppMenu>>,
|
||||||
|
selected_ix: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppMenuBar {
|
||||||
|
/// Create a new app menu bar.
|
||||||
|
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
|
||||||
|
cx.new(|cx| {
|
||||||
|
let menu_bar = cx.entity();
|
||||||
|
let menus = cx
|
||||||
|
.get_menus()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(ix, menu)| AppMenu::new(ix, menu, menu_bar.clone(), window, cx))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Self {
|
||||||
|
selected_ix: None,
|
||||||
|
menus,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn move_left(&mut self, _: &SelectLeft, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
let Some(selected_ix) = self.selected_ix else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let new_ix = if selected_ix == 0 {
|
||||||
|
self.menus.len().saturating_sub(1)
|
||||||
|
} else {
|
||||||
|
selected_ix.saturating_sub(1)
|
||||||
|
};
|
||||||
|
self.set_selected_ix(Some(new_ix), window, cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn move_right(&mut self, _: &SelectRight, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
let Some(selected_ix) = self.selected_ix else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let new_ix = if selected_ix + 1 >= self.menus.len() {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
selected_ix + 1
|
||||||
|
};
|
||||||
|
self.set_selected_ix(Some(new_ix), window, cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
self.set_selected_ix(None, window, cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_selected_ix(&mut self, ix: Option<usize>, _: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
self.selected_ix = ix;
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn has_activated_menu(&self) -> bool {
|
||||||
|
self.selected_ix.is_some()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for AppMenuBar {
|
||||||
|
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
h_flex()
|
||||||
|
.id("app-menu-bar")
|
||||||
|
.key_context(CONTEXT)
|
||||||
|
.on_action(cx.listener(Self::move_left))
|
||||||
|
.on_action(cx.listener(Self::move_right))
|
||||||
|
.on_action(cx.listener(Self::cancel))
|
||||||
|
.size_full()
|
||||||
|
.gap_x_1()
|
||||||
|
.overflow_x_scroll()
|
||||||
|
.children(self.menus.clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A menu in the menu bar.
|
||||||
|
pub(super) struct AppMenu {
|
||||||
|
menu_bar: Entity<AppMenuBar>,
|
||||||
|
ix: usize,
|
||||||
|
name: SharedString,
|
||||||
|
menu: OwnedMenu,
|
||||||
|
popup_menu: Option<Entity<PopupMenu>>,
|
||||||
|
|
||||||
|
_subscription: Option<Subscription>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppMenu {
|
||||||
|
pub(super) fn new(
|
||||||
|
ix: usize,
|
||||||
|
menu: &OwnedMenu,
|
||||||
|
menu_bar: Entity<AppMenuBar>,
|
||||||
|
_: &mut Window,
|
||||||
|
cx: &mut App,
|
||||||
|
) -> Entity<Self> {
|
||||||
|
let name = menu.name.clone();
|
||||||
|
cx.new(|_| Self {
|
||||||
|
ix,
|
||||||
|
menu_bar,
|
||||||
|
name,
|
||||||
|
menu: menu.clone(),
|
||||||
|
popup_menu: None,
|
||||||
|
_subscription: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_popup_menu(
|
||||||
|
&mut self,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) -> Entity<PopupMenu> {
|
||||||
|
let popup_menu = match self.popup_menu.as_ref() {
|
||||||
|
None => {
|
||||||
|
let items = self.menu.items.clone();
|
||||||
|
let popup_menu = PopupMenu::build(window, cx, |menu, window, cx| {
|
||||||
|
menu.when_some(window.focused(cx), |this, handle| {
|
||||||
|
this.action_context(handle)
|
||||||
|
})
|
||||||
|
.with_menu_items(items, window, cx)
|
||||||
|
});
|
||||||
|
popup_menu.read(cx).focus_handle(cx).focus(window);
|
||||||
|
self._subscription =
|
||||||
|
Some(cx.subscribe_in(&popup_menu, window, Self::handle_dismiss));
|
||||||
|
self.popup_menu = Some(popup_menu.clone());
|
||||||
|
|
||||||
|
popup_menu
|
||||||
|
}
|
||||||
|
Some(menu) => menu.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let focus_handle = popup_menu.read(cx).focus_handle(cx);
|
||||||
|
if !focus_handle.contains_focused(window, cx) {
|
||||||
|
focus_handle.focus(window);
|
||||||
|
}
|
||||||
|
|
||||||
|
popup_menu
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_dismiss(
|
||||||
|
&mut self,
|
||||||
|
_: &Entity<PopupMenu>,
|
||||||
|
_: &DismissEvent,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) {
|
||||||
|
self._subscription.take();
|
||||||
|
self.popup_menu.take();
|
||||||
|
self.menu_bar.update(cx, |state, cx| {
|
||||||
|
state.cancel(&Cancel, window, cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_trigger_click(
|
||||||
|
&mut self,
|
||||||
|
_: &ClickEvent,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) {
|
||||||
|
let is_selected = self.menu_bar.read(cx).selected_ix == Some(self.ix);
|
||||||
|
|
||||||
|
self.menu_bar.update(cx, |state, cx| {
|
||||||
|
let new_ix = if is_selected { None } else { Some(self.ix) };
|
||||||
|
state.set_selected_ix(new_ix, window, cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_hover(&mut self, hovered: &bool, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
if !*hovered {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let has_activated_menu = self.menu_bar.read(cx).has_activated_menu();
|
||||||
|
if !has_activated_menu {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.menu_bar.update(cx, |state, cx| {
|
||||||
|
state.set_selected_ix(Some(self.ix), window, cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for AppMenu {
|
||||||
|
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
let menu_bar = self.menu_bar.read(cx);
|
||||||
|
let is_selected = menu_bar.selected_ix == Some(self.ix);
|
||||||
|
|
||||||
|
div()
|
||||||
|
.id(self.ix)
|
||||||
|
.relative()
|
||||||
|
.child(
|
||||||
|
Button::new("menu")
|
||||||
|
.small()
|
||||||
|
.py_0p5()
|
||||||
|
.xsmall()
|
||||||
|
.ghost()
|
||||||
|
.label(self.name.clone())
|
||||||
|
.selected(is_selected)
|
||||||
|
.on_click(cx.listener(Self::handle_trigger_click)),
|
||||||
|
)
|
||||||
|
.on_hover(cx.listener(Self::handle_hover))
|
||||||
|
.when(is_selected, |this| {
|
||||||
|
this.child(deferred(
|
||||||
|
anchored()
|
||||||
|
.anchor(gpui::Corner::TopLeft)
|
||||||
|
.snap_to_window_with_margin(px(8.))
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.size_full()
|
||||||
|
.occlude()
|
||||||
|
.top_1()
|
||||||
|
.child(self.build_popup_menu(window, cx)),
|
||||||
|
),
|
||||||
|
))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,10 +3,10 @@ use std::rc::Rc;
|
|||||||
|
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
anchored, deferred, div, px, relative, AnyElement, App, Context, Corner, DismissEvent,
|
anchored, deferred, div, px, relative, AnyElement, App, Context, Corner, DismissEvent, Element,
|
||||||
DispatchPhase, Element, ElementId, Entity, Focusable, GlobalElementId, InteractiveElement,
|
ElementId, Entity, Focusable, GlobalElementId, InspectorElementId, InteractiveElement,
|
||||||
IntoElement, MouseButton, MouseDownEvent, ParentElement, Pixels, Point, Position, Size,
|
IntoElement, MouseButton, MouseDownEvent, ParentElement, Pixels, Point, Position, Stateful,
|
||||||
Stateful, Style, Window,
|
Style, Subscription, Window,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::popup_menu::PopupMenu;
|
use crate::popup_menu::PopupMenu;
|
||||||
@@ -22,13 +22,12 @@ pub trait ContextMenuExt: ParentElement + Sized {
|
|||||||
|
|
||||||
impl<E> ContextMenuExt for Stateful<E> where E: ParentElement {}
|
impl<E> ContextMenuExt for Stateful<E> where E: ParentElement {}
|
||||||
|
|
||||||
type Menu =
|
|
||||||
Option<Box<dyn Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static>>;
|
|
||||||
|
|
||||||
/// A context menu that can be shown on right-click.
|
/// A context menu that can be shown on right-click.
|
||||||
|
#[allow(clippy::type_complexity)]
|
||||||
pub struct ContextMenu {
|
pub struct ContextMenu {
|
||||||
id: ElementId,
|
id: ElementId,
|
||||||
menu: Menu,
|
menu:
|
||||||
|
Option<Box<dyn Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static>>,
|
||||||
anchor: Corner,
|
anchor: Corner,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,20 +75,28 @@ impl IntoElement for ContextMenu {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct ContextMenuSharedState {
|
||||||
|
menu_view: Option<Entity<PopupMenu>>,
|
||||||
|
open: bool,
|
||||||
|
position: Point<Pixels>,
|
||||||
|
_subscription: Option<Subscription>,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct ContextMenuState {
|
pub struct ContextMenuState {
|
||||||
menu_view: Rc<RefCell<Option<Entity<PopupMenu>>>>,
|
|
||||||
menu_element: Option<AnyElement>,
|
menu_element: Option<AnyElement>,
|
||||||
open: Rc<RefCell<bool>>,
|
shared_state: Rc<RefCell<ContextMenuSharedState>>,
|
||||||
position: Rc<RefCell<Point<Pixels>>>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ContextMenuState {
|
impl Default for ContextMenuState {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
menu_view: Rc::new(RefCell::new(None)),
|
|
||||||
menu_element: None,
|
menu_element: None,
|
||||||
open: Rc::new(RefCell::new(false)),
|
shared_state: Rc::new(RefCell::new(ContextMenuSharedState {
|
||||||
|
menu_view: None,
|
||||||
|
open: false,
|
||||||
position: Default::default(),
|
position: Default::default(),
|
||||||
|
_subscription: None,
|
||||||
|
})),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -106,6 +113,7 @@ impl Element for ContextMenu {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::field_reassign_with_default)]
|
||||||
fn request_layout(
|
fn request_layout(
|
||||||
&mut self,
|
&mut self,
|
||||||
id: Option<&gpui::GlobalElementId>,
|
id: Option<&gpui::GlobalElementId>,
|
||||||
@@ -113,29 +121,27 @@ impl Element for ContextMenu {
|
|||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
cx: &mut App,
|
cx: &mut App,
|
||||||
) -> (gpui::LayoutId, Self::RequestLayoutState) {
|
) -> (gpui::LayoutId, Self::RequestLayoutState) {
|
||||||
|
let mut style = Style::default();
|
||||||
|
// Set the layout style relative to the table view to get same size.
|
||||||
|
style.position = Position::Absolute;
|
||||||
|
style.flex_grow = 1.0;
|
||||||
|
style.flex_shrink = 1.0;
|
||||||
|
style.size.width = relative(1.).into();
|
||||||
|
style.size.height = relative(1.).into();
|
||||||
|
|
||||||
let anchor = self.anchor;
|
let anchor = self.anchor;
|
||||||
let style = Style {
|
|
||||||
position: Position::Absolute,
|
|
||||||
flex_grow: 1.0,
|
|
||||||
flex_shrink: 1.0,
|
|
||||||
size: Size {
|
|
||||||
width: relative(1.).into(),
|
|
||||||
height: relative(1.).into(),
|
|
||||||
},
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
self.with_element_state(
|
self.with_element_state(
|
||||||
id.unwrap(),
|
id.unwrap(),
|
||||||
window,
|
window,
|
||||||
cx,
|
cx,
|
||||||
|_, state: &mut ContextMenuState, window, cx| {
|
|_, state: &mut ContextMenuState, window, cx| {
|
||||||
let position = state.position.clone();
|
let (position, open) = {
|
||||||
let position = position.borrow();
|
let shared_state = state.shared_state.borrow();
|
||||||
let open = state.open.clone();
|
(shared_state.position, shared_state.open)
|
||||||
let menu_view = state.menu_view.borrow().clone();
|
};
|
||||||
|
let menu_view = state.shared_state.borrow().menu_view.clone();
|
||||||
let (menu_element, menu_layout_id) = if *open.borrow() {
|
let (menu_element, menu_layout_id) = if open {
|
||||||
let has_menu_item = menu_view
|
let has_menu_item = menu_view
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|menu| !menu.read(cx).is_empty())
|
.map(|menu| !menu.read(cx).is_empty())
|
||||||
@@ -144,12 +150,14 @@ impl Element for ContextMenu {
|
|||||||
if has_menu_item {
|
if has_menu_item {
|
||||||
let mut menu_element = deferred(
|
let mut menu_element = deferred(
|
||||||
anchored()
|
anchored()
|
||||||
.position(*position)
|
.position(position)
|
||||||
.snap_to_window_with_margin(px(8.))
|
.snap_to_window_with_margin(px(8.))
|
||||||
.anchor(anchor)
|
.anchor(anchor)
|
||||||
.when_some(menu_view, |this, menu| {
|
.when_some(menu_view, |this, menu| {
|
||||||
// Focus the menu, so that can be handle the action.
|
// Focus the menu, so that can be handle the action.
|
||||||
|
if !menu.focus_handle(cx).contains_focused(window, cx) {
|
||||||
menu.focus_handle(cx).focus(window);
|
menu.focus_handle(cx).focus(window);
|
||||||
|
}
|
||||||
|
|
||||||
this.child(div().occlude().child(menu.clone()))
|
this.child(div().occlude().child(menu.clone()))
|
||||||
}),
|
}),
|
||||||
@@ -188,7 +196,7 @@ impl Element for ContextMenu {
|
|||||||
fn prepaint(
|
fn prepaint(
|
||||||
&mut self,
|
&mut self,
|
||||||
_: Option<&gpui::GlobalElementId>,
|
_: Option<&gpui::GlobalElementId>,
|
||||||
_: Option<&gpui::InspectorElementId>,
|
_: Option<&InspectorElementId>,
|
||||||
_: gpui::Bounds<gpui::Pixels>,
|
_: gpui::Bounds<gpui::Pixels>,
|
||||||
request_layout: &mut Self::RequestLayoutState,
|
request_layout: &mut Self::RequestLayoutState,
|
||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
@@ -202,7 +210,7 @@ impl Element for ContextMenu {
|
|||||||
fn paint(
|
fn paint(
|
||||||
&mut self,
|
&mut self,
|
||||||
id: Option<&gpui::GlobalElementId>,
|
id: Option<&gpui::GlobalElementId>,
|
||||||
_: Option<&gpui::InspectorElementId>,
|
_: Option<&InspectorElementId>,
|
||||||
bounds: gpui::Bounds<gpui::Pixels>,
|
bounds: gpui::Bounds<gpui::Pixels>,
|
||||||
request_layout: &mut Self::RequestLayoutState,
|
request_layout: &mut Self::RequestLayoutState,
|
||||||
_: &mut Self::PrepaintState,
|
_: &mut Self::PrepaintState,
|
||||||
@@ -222,34 +230,35 @@ impl Element for ContextMenu {
|
|||||||
window,
|
window,
|
||||||
cx,
|
cx,
|
||||||
|_view, state: &mut ContextMenuState, window, _| {
|
|_view, state: &mut ContextMenuState, window, _| {
|
||||||
let position = state.position.clone();
|
let shared_state = state.shared_state.clone();
|
||||||
let open = state.open.clone();
|
|
||||||
let menu_view = state.menu_view.clone();
|
|
||||||
|
|
||||||
// When right mouse click, to build content menu, and show it at the mouse position.
|
// When right mouse click, to build content menu, and show it at the mouse position.
|
||||||
window.on_mouse_event(move |event: &MouseDownEvent, phase, window, cx| {
|
window.on_mouse_event(move |event: &MouseDownEvent, phase, window, cx| {
|
||||||
if phase == DispatchPhase::Bubble
|
if phase.bubble()
|
||||||
&& event.button == MouseButton::Right
|
&& event.button == MouseButton::Right
|
||||||
&& bounds.contains(&event.position)
|
&& bounds.contains(&event.position)
|
||||||
{
|
{
|
||||||
*position.borrow_mut() = event.position;
|
{
|
||||||
*open.borrow_mut() = true;
|
let mut shared_state = shared_state.borrow_mut();
|
||||||
|
shared_state.position = event.position;
|
||||||
|
shared_state.open = true;
|
||||||
|
}
|
||||||
|
|
||||||
let menu = PopupMenu::build(window, cx, |menu, window, cx| {
|
let menu = PopupMenu::build(window, cx, |menu, window, cx| {
|
||||||
(builder)(menu, window, cx)
|
(builder)(menu, window, cx)
|
||||||
})
|
})
|
||||||
.into_element();
|
.into_element();
|
||||||
|
|
||||||
let open = open.clone();
|
let _subscription = window.subscribe(&menu, cx, {
|
||||||
window
|
let shared_state = shared_state.clone();
|
||||||
.subscribe(&menu, cx, move |_, _: &DismissEvent, window, _| {
|
move |_, _: &DismissEvent, window, _| {
|
||||||
*open.borrow_mut() = false;
|
shared_state.borrow_mut().open = false;
|
||||||
window.refresh();
|
window.refresh();
|
||||||
})
|
}
|
||||||
.detach();
|
});
|
||||||
|
|
||||||
*menu_view.borrow_mut() = Some(menu);
|
|
||||||
|
|
||||||
|
shared_state.borrow_mut().menu_view = Some(menu.clone());
|
||||||
|
shared_state.borrow_mut()._subscription = Some(_subscription);
|
||||||
window.refresh();
|
window.refresh();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
use gpui::prelude::FluentBuilder as _;
|
||||||
|
use gpui::{
|
||||||
|
AnyElement, App, ClickEvent, ElementId, InteractiveElement, IntoElement, MouseButton,
|
||||||
|
ParentElement, RenderOnce, SharedString, StatefulInteractiveElement as _, StyleRefinement,
|
||||||
|
Styled, Window,
|
||||||
|
};
|
||||||
|
use smallvec::SmallVec;
|
||||||
|
use theme::ActiveTheme;
|
||||||
|
|
||||||
|
use crate::{h_flex, Disableable, StyledExt};
|
||||||
|
|
||||||
|
#[derive(IntoElement)]
|
||||||
|
#[allow(clippy::type_complexity)]
|
||||||
|
pub(crate) struct MenuItemElement {
|
||||||
|
id: ElementId,
|
||||||
|
group_name: SharedString,
|
||||||
|
style: StyleRefinement,
|
||||||
|
disabled: bool,
|
||||||
|
selected: bool,
|
||||||
|
on_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
|
||||||
|
on_hover: Option<Box<dyn Fn(&bool, &mut Window, &mut App) + 'static>>,
|
||||||
|
children: SmallVec<[AnyElement; 2]>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MenuItemElement {
|
||||||
|
pub fn new(id: impl Into<ElementId>, group_name: impl Into<SharedString>) -> Self {
|
||||||
|
let id: ElementId = id.into();
|
||||||
|
Self {
|
||||||
|
id: id.clone(),
|
||||||
|
group_name: group_name.into(),
|
||||||
|
style: StyleRefinement::default(),
|
||||||
|
disabled: false,
|
||||||
|
selected: false,
|
||||||
|
on_click: None,
|
||||||
|
on_hover: None,
|
||||||
|
children: SmallVec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set ListItem as the selected item style.
|
||||||
|
pub fn selected(mut self, selected: bool) -> Self {
|
||||||
|
self.selected = selected;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn disabled(mut self, disabled: bool) -> Self {
|
||||||
|
self.disabled = disabled;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn on_click(
|
||||||
|
mut self,
|
||||||
|
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
|
||||||
|
) -> Self {
|
||||||
|
self.on_click = Some(Box::new(handler));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set a handler for when the mouse enters the MenuItem.
|
||||||
|
#[allow(unused)]
|
||||||
|
pub fn on_hover(mut self, handler: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
|
||||||
|
self.on_hover = Some(Box::new(handler));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Disableable for MenuItemElement {
|
||||||
|
fn disabled(mut self, disabled: bool) -> Self {
|
||||||
|
self.disabled = disabled;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Styled for MenuItemElement {
|
||||||
|
fn style(&mut self) -> &mut gpui::StyleRefinement {
|
||||||
|
&mut self.style
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ParentElement for MenuItemElement {
|
||||||
|
fn extend(&mut self, elements: impl IntoIterator<Item = gpui::AnyElement>) {
|
||||||
|
self.children.extend(elements);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RenderOnce for MenuItemElement {
|
||||||
|
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||||
|
h_flex()
|
||||||
|
.id(self.id)
|
||||||
|
.group(&self.group_name)
|
||||||
|
.gap_x_2()
|
||||||
|
.py_1()
|
||||||
|
.px_2()
|
||||||
|
.text_base()
|
||||||
|
.text_color(cx.theme().text)
|
||||||
|
.relative()
|
||||||
|
.items_center()
|
||||||
|
.justify_between()
|
||||||
|
.refine_style(&self.style)
|
||||||
|
.when_some(self.on_hover, |this, on_hover| {
|
||||||
|
this.on_hover(move |hovered, window, cx| (on_hover)(hovered, window, cx))
|
||||||
|
})
|
||||||
|
.when(!self.disabled, |this| {
|
||||||
|
this.group_hover(self.group_name, |this| {
|
||||||
|
this.bg(cx.theme().elevated_surface_background)
|
||||||
|
.text_color(cx.theme().text)
|
||||||
|
})
|
||||||
|
.when(self.selected, |this| {
|
||||||
|
this.bg(cx.theme().elevated_surface_background)
|
||||||
|
.text_color(cx.theme().text)
|
||||||
|
})
|
||||||
|
.when_some(self.on_click, |this, on_click| {
|
||||||
|
this.on_mouse_down(MouseButton::Left, move |_, _, cx| {
|
||||||
|
cx.stop_propagation();
|
||||||
|
})
|
||||||
|
.on_click(on_click)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.when(self.disabled, |this| this.text_color(cx.theme().text_muted))
|
||||||
|
.children(self.children)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
use gpui::App;
|
||||||
|
|
||||||
|
mod app_menu_bar;
|
||||||
|
mod menu_item;
|
||||||
|
|
||||||
|
pub mod context_menu;
|
||||||
|
pub mod popup_menu;
|
||||||
|
|
||||||
|
pub use app_menu_bar::AppMenuBar;
|
||||||
|
|
||||||
|
pub(crate) fn init(cx: &mut App) {
|
||||||
|
app_menu_bar::init(cx);
|
||||||
|
popup_menu::init(cx);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -14,13 +14,7 @@ use crate::{Selectable, StyledExt as _};
|
|||||||
|
|
||||||
const CONTEXT: &str = "Popover";
|
const CONTEXT: &str = "Popover";
|
||||||
|
|
||||||
actions!(
|
actions!(popover, [Escape]);
|
||||||
popover,
|
|
||||||
[
|
|
||||||
/// Action when user presses escape button
|
|
||||||
Escape
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
pub fn init(cx: &mut App) {
|
pub fn init(cx: &mut App) {
|
||||||
cx.bind_keys([KeyBinding::new("escape", Escape, Some(CONTEXT))])
|
cx.bind_keys([KeyBinding::new("escape", Escape, Some(CONTEXT))])
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ pub trait PopupMenuExt: Styled + Selectable + InteractiveElement + IntoElement +
|
|||||||
impl PopupMenuExt for Button {}
|
impl PopupMenuExt for Button {}
|
||||||
|
|
||||||
enum PopupMenuItem {
|
enum PopupMenuItem {
|
||||||
|
Title(SharedString),
|
||||||
Separator,
|
Separator,
|
||||||
Item {
|
Item {
|
||||||
icon: Option<Icon>,
|
icon: Option<Icon>,
|
||||||
@@ -314,6 +315,20 @@ impl PopupMenu {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Add a title menu item
|
||||||
|
pub fn title(mut self, label: impl Into<SharedString>) -> Self {
|
||||||
|
if self.menu_items.is_empty() {
|
||||||
|
return self;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(PopupMenuItem::Title(_)) = self.menu_items.last() {
|
||||||
|
return self;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.menu_items.push(PopupMenuItem::Title(label.into()));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
/// Add a separator Menu Item
|
/// Add a separator Menu Item
|
||||||
pub fn separator(mut self) -> Self {
|
pub fn separator(mut self) -> Self {
|
||||||
if self.menu_items.is_empty() {
|
if self.menu_items.is_empty() {
|
||||||
@@ -588,6 +603,15 @@ impl Render for PopupMenu {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
match item {
|
match item {
|
||||||
|
PopupMenuItem::Title(label) => {
|
||||||
|
this.child(
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
|
.font_semibold()
|
||||||
|
.text_color(cx.theme().text_muted)
|
||||||
|
.child(label.clone())
|
||||||
|
)
|
||||||
|
},
|
||||||
PopupMenuItem::Separator => this.h_auto().p_0().disabled(true).child(
|
PopupMenuItem::Separator => this.h_auto().p_0().disabled(true).child(
|
||||||
div()
|
div()
|
||||||
.rounded_none()
|
.rounded_none()
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user