11 Commits
59 changed files with 2709 additions and 1546 deletions
Generated
+597 -658
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "desktop", "web"]
default-members = ["desktop"]
[workspace.package]
version = "1.0.0-beta5"
version = "1.0.1"
edition = "2024"
publish = false
@@ -42,7 +42,7 @@ schemars = "1"
smallvec = "1.14.0"
smol = "2"
webbrowser = "1.0.4"
tracing-subscriber = { version = "0.3.18", features = ["fmt", "env-filter"] }
tracing-subscriber = { version = "0.3.18", features = ["fmt"] }
errno = { version = "0.3.14", default-features = false }
instant = "0.1"
-120
View File
@@ -1,125 +1,5 @@
![Coop](/docs/coop.png)
<p>
<a href="https://github.com/reyakov/coop/actions/workflows/rust.yml">
<img alt="Actions" src="https://github.com/reyakov/coop/actions/workflows/rust.yml/badge.svg">
</a>
<img alt="GitHub repo size" src="https://img.shields.io/github/repo-size/reyakov/coop">
<img alt="GitHub issues" src="https://img.shields.io/github/issues-raw/reyakov/coop">
<img alt="GitHub pull requests" src="https://img.shields.io/github/issues-pr/reyakov/coop">
</p>
Coop is a simple, fast, and reliable nostr client for secure messaging across all platforms.
### Screenshots
<p float="left">
<img src="/docs/mac_01.png" width="250" />
<img src="/docs/mac_02.png" width="250" />
<img src="/docs/mac_03.png" width="250" />
<img src="/docs/mac_04.png" width="250" />
<img src="/docs/mac_05.png" width="250" />
<img src="/docs/mac_06.png" width="250" />
<img src="/docs/mac_07.png" width="250" />
<img src="/docs/mac_08.png" width="250" />
<img src="/docs/mac_09.png" width="250" />
<img src="/docs/linux_01.png" width="250" />
<img src="/docs/linux_02.png" width="250" />
<img src="/docs/linux_03.png" width="250" />
<img src="/docs/linux_04.png" width="250" />
<img src="/docs/linux_05.png" width="250" />
</p>
### Installation
To install Coop, follow these steps:
1. **Download the Latest Release**:
- Visit the [Coop Releases page on GitHub](https://github.com/reyakov/coop/releases).
- Download the package that matches your operating system (Windows, macOS, or Linux).
2. **Install**:
- **Windows**: Run the downloaded `.exe` installer and follow the on-screen instructions.
- **macOS**: Open the downloaded `.dmg` file and drag Coop to your Applications folder.
- **Linux**: Run the downloaded `.flatpak` or `.snap` installer and follow the on-screen instructions.
3. **Run Coop**:
- Launch Coop from your Applications folder (macOS) or by double-clicking the executable (Windows/Linux).
For more detailed instructions, refer to the [Release Notes](#) on GitHub.
### Developing Coop
Coop is built using Rust and GPUI. All Nostr related stuffs handled by [Rust Nostr SDK](https://github.com/rust-nostr/nostr)
#### Prerequisites
- **Rust Toolchain**: Ensure you have Rust installed. If not, you can install it using [rustup](https://rustup.rs/).
- **Cargo**: Rust's package manager, which comes bundled with the Rust installation.
- **Git**: To clone the repository and manage version control.
#### Setting Up the Development Environment
1. Clone the repository:
```bash
git clone https://github.com/reyakov/coop.git
cd coop
```
2.1 Install Linux dependencies:
```bash
./script/linux
```
2.2 Install FreeBSD dependencies:
```bash
./script/freebsd
```
3. Install Rust dependencies:
```bash
cargo build
```
4. Run the app:
```bash
cargo run
```
#### Building for Production
To build Coop for production, use the following command:
```bash
cargo build --release
```
This will generate an optimized binary in the `target/release` directory.
#### Contributing Code
If you'd like to contribute to Coop, please follow these steps:
1. Fork the repository.
2. Create a new branch for your feature or bugfix.
3. Make your changes and ensure all tests pass.
4. Submit a pull request with a detailed description of your changes.
For more information, see the [Contributing](#contributing) section.
#### Additional Resources
- [Rust Nostr](https://github.com/rust-nostr/nostr/)
- [GPUI](https://www.gpui.rs/)
- [GPUI Components](https://github.com/longbridge/gpui-component/)
- [Coop Issue Tracker](https://github.com/reyakov/coop/issues/)
### License
Copyright (C) 2025 Ren Amamiya & other Coop contributors
+1 -11
View File
@@ -5,18 +5,8 @@ edition.workspace = true
publish.workspace = true
[dependencies]
common = { path = "../common" }
gpui.workspace = true
instant.workspace = true
anyhow.workspace = true
log.workspace = true
smallvec.workspace = true
serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true
semver = "1.0.27"
tempfile = "3.23.0"
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
smol.workspace = true
gpui-updater = { git = "https://github.com/AprilNEA/gpui-updater", tag = "v0.0.6", features = ["gpui"] }
+147 -500
View File
@@ -1,25 +1,11 @@
#![cfg(not(target_arch = "wasm32"))]
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use gpui::{App, AppContext, Context, Entity, Global, SharedString, Subscription, Window};
use gpui_updater::{EngineConfig, GitHubSource, UpdateStatus, Updater, Version};
use instant::{Duration, Instant};
use anyhow::{Context as AnyhowContext, Error, anyhow};
use gpui::http_client::{AsyncBody, HttpClient};
use gpui::{
App, AppContext, AsyncApp, BackgroundExecutor, Context, Entity, Global, Subscription, Task,
Window,
};
use instant::Duration;
use semver::Version;
use serde::Deserialize;
use smallvec::{SmallVec, smallvec};
use smol::fs::File;
use smol::io::AsyncReadExt;
use smol::process::Command;
const GITHUB_API_URL: &str = "https://api.github.com";
const COOP_UPDATE_EXPLANATION: &str = "COOP_UPDATE_EXPLANATION";
const COOP_BUNDLE_TYPE: &str = "COOP_BUNDLE_TYPE";
fn get_github_repo_owner() -> String {
std::env::var("COOP_GITHUB_REPO_OWNER").unwrap_or_else(|_| "reyakov".to_string())
@@ -29,15 +15,27 @@ fn get_github_repo_name() -> String {
std::env::var("COOP_GITHUB_REPO_NAME").unwrap_or_else(|_| "coop".to_string())
}
fn is_flatpak_installation() -> bool {
// Check if app is installed via Flatpak
std::env::var("FLATPAK_ID").is_ok() || std::env::var(COOP_UPDATE_EXPLANATION).is_ok()
/// Whether updates are managed by an external distribution channel
/// (Flatpak/Snap), in which case the in-app updater must not run.
fn uses_managed_updates() -> bool {
// The Flatpak runtime exports `FLATPAK_ID` inside the sandbox.
std::env::var("FLATPAK_ID").is_ok()
// Allow opting out of in-app updates via an explicit environment variable.
|| std::env::var(COOP_UPDATE_EXPLANATION).is_ok()
// The Snap package sets `COOP_BUNDLE_TYPE=snap` (see snapcraft.yaml.in).
|| std::env::var(COOP_BUNDLE_TYPE).is_ok_and(|value| value == "snap")
}
/// Initialize the auto-update system.
///
/// Skips initialization when updates are handled by an external distribution
/// channel (Flatpak/Snap). Otherwise creates the global [`AutoUpdater`]
/// entity and schedules a check for updates after a 2-minute delay.
pub fn init(window: &mut Window, cx: &mut App) {
// Skip auto-update initialization if installed via Flatpak
if is_flatpak_installation() {
log::info!("Skipping auto-update initialization: App is installed via Flatpak");
if uses_managed_updates() {
log::info!(
"Skipping auto-update initialization: App is installed via a managed distribution channel (Flatpak/Snap)"
);
return;
}
@@ -48,516 +46,165 @@ struct GlobalAutoUpdater(Entity<AutoUpdater>);
impl Global for GlobalAutoUpdater {}
#[cfg(not(target_os = "windows"))]
struct InstallerDir(tempfile::TempDir);
/// Observable auto-update status — re-exported from [`gpui_updater::UpdateStatus`].
pub use gpui_updater::UpdateStatus as AutoUpdateStatus;
#[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 {
Idle,
Checking,
Checked { download_url: String },
Installing,
Updated,
Errored { msg: Box<String> },
}
impl AsRef<AutoUpdateStatus> for AutoUpdateStatus {
fn as_ref(&self) -> &AutoUpdateStatus {
self
}
}
impl AutoUpdateStatus {
pub fn is_updating(&self) -> bool {
matches!(self, Self::Checked { .. } | Self::Installing)
}
pub fn is_updated(&self) -> bool {
matches!(self, Self::Updated)
}
pub fn checked(download_url: String) -> Self {
Self::Checked { download_url }
}
pub fn error(e: String) -> Self {
Self::Errored { msg: Box::new(e) }
}
}
#[derive(Debug, Deserialize)]
pub struct GitHubRelease {
pub tag_name: String,
pub assets: Vec<GitHubAsset>,
}
#[derive(Debug, Deserialize)]
pub struct GitHubAsset {
pub name: String,
pub browser_download_url: String,
}
#[derive(Debug)]
/// The global auto-updater entity.
///
/// Wraps [`gpui_updater::Updater`] with Coop-specific configuration
/// (GitHub repo, Flatpak detection, delayed auto-check).
///
/// Retrieve the global instance via [`AutoUpdater::global`].
pub struct AutoUpdater {
/// Current status of the auto updater
pub status: AutoUpdateStatus,
/// Current version of the application
/// The underlying gpui-updater entity that does the heavy lifting.
pub updater: Entity<Updater>,
/// Currently running app version.
pub version: Version,
/// Event subscriptions
_subscriptions: SmallVec<[Subscription; 1]>,
/// Background tasks
tasks: Vec<Task<Result<(), Error>>>,
/// Keeps the observer subscription alive.
_subscription: Subscription,
/// When the last error was recorded, so we can reset to idle after 5s.
error_time: Option<Instant>,
}
impl AutoUpdater {
/// Retrieve the global auto updater instance
/// Whether auto-update is available for this installation.
///
/// Returns `false` on managed distribution channels (Flatpak/Snap), where
/// updates are handled by the channel and no global updater is created.
pub fn is_available(cx: &App) -> bool {
cx.try_global::<GlobalAutoUpdater>().is_some()
}
/// Retrieve the global auto updater instance, if one was initialized.
pub fn try_global(cx: &App) -> Option<Entity<Self>> {
cx.try_global::<GlobalAutoUpdater>()
.map(|global| global.0.clone())
}
/// Retrieve the global auto updater instance.
///
/// # Panics
///
/// Panics when auto-update is not available for this installation. Prefer
/// [`AutoUpdater::try_global`] when the installation type is not known at
/// compile time (e.g. Flatpak/Snap).
pub fn global(cx: &App) -> Entity<Self> {
cx.global::<GlobalAutoUpdater>().0.clone()
}
/// Set the global auto updater instance
fn set_global(state: Entity<Self>, cx: &mut App) {
cx.set_global(GlobalAutoUpdater(state));
}
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let version = Version::parse(env!("CARGO_PKG_VERSION")).unwrap();
let mut subscriptions = smallvec![];
subscriptions.push(
// Observe the status
cx.observe_self(|this, cx| {
if let AutoUpdateStatus::Checked { download_url } = this.status.clone() {
this.download_and_install(&download_url, cx);
}
}),
);
// Run at the end of current cycle
cx.defer_in(window, |this, _window, cx| {
this.check(cx);
});
Self {
status: AutoUpdateStatus::Idle,
version,
tasks: vec![],
_subscriptions: subscriptions,
}
}
fn set_status(&mut self, status: AutoUpdateStatus, cx: &mut Context<Self>) {
self.status = status;
cx.notify();
}
fn check(&mut self, cx: &mut Context<Self>) {
let version = self.version.clone();
let duration = Duration::from_secs(120);
let task = self.check_for_updates(version, cx);
// Check for updates after 2 minutes
self.tasks.push(cx.spawn(async move |this, cx| {
cx.background_executor().timer(duration).await;
// Update the status to checking
this.update(cx, |this, cx| {
this.set_status(AutoUpdateStatus::Checking, cx);
})?;
match task.await {
Ok(download_url) => {
// Update the status to checked with download URL
this.update(cx, |this, cx| {
this.set_status(AutoUpdateStatus::checked(download_url), cx);
})?;
}
Err(e) => {
log::warn!("Failed to check for updates: {e}");
this.update(cx, |this, cx| {
this.set_status(AutoUpdateStatus::Idle, cx);
})?;
}
}
Ok(())
}));
}
fn check_for_updates(&self, version: Version, cx: &App) -> Task<Result<String, Error>> {
let http_client = cx.http_client();
let repo_owner = get_github_repo_owner();
let repo_name = get_github_repo_name();
cx.background_spawn(async move {
let url = format!(
"{}/repos/{}/{}/releases/latest",
GITHUB_API_URL, repo_owner, repo_name
);
let source =
GitHubSource::new(&repo_owner, &repo_name).asset_contains(match std::env::consts::OS {
"macos" => "macos",
"linux" => "linux",
_ => "",
});
let async_body = AsyncBody::default();
let mut body = Vec::new();
let mut response = http_client.get(&url, async_body, false).await?;
let updater: Entity<Updater> =
cx.new(|cx| Updater::new(source, EngineConfig::new(version.clone()), cx));
// Read the response body into a vector
response.body_mut().read_to_end(&mut body).await?;
// When an update becomes available, automatically download and install it.
let subscription = cx.observe(&updater, |this: &mut AutoUpdater, _updater, cx| {
let status = this.updater.read(cx).status().clone();
if !response.status().is_success() {
return Err(anyhow!("GitHub API returned error: {}", response.status()));
if matches!(status, UpdateStatus::Available(_)) {
this.updater.update(cx, |updater, cx| {
updater.download_and_install(cx);
});
}
// Parse the response body as JSON
let release: GitHubRelease = serde_json::from_slice(&body)?;
// Parse version from tag (remove 'v' prefix if present)
let tag_version = release.tag_name.trim_start_matches('v');
let new_version = Version::parse(tag_version).context(format!(
"Failed to parse version from tag: {}",
release.tag_name
))?;
if new_version > version {
// Find the appropriate asset for the current platform
let current_os = std::env::consts::OS;
let asset_name = match current_os {
"macos" => "Coop.dmg",
"linux" => "coop.tar.gz",
"windows" => "Coop.exe",
_ => return Err(anyhow!("Unsupported OS: {}", current_os)),
};
let download_url = release
.assets
.iter()
.find(|asset| asset.name == asset_name)
.map(|asset| asset.browser_download_url.clone())
.context(format!(
"No {} asset found in release {}",
asset_name, release.tag_name
))?;
Ok(download_url)
if matches!(status, UpdateStatus::Errored(_)) {
this.error_time = Some(Instant::now());
cx.spawn(async move |this, cx| {
cx.background_executor().timer(Duration::from_secs(5)).await;
this.update(cx, |_this, cx| cx.notify()).ok();
})
.detach();
} else {
Err(anyhow!(
"No update available. Current: {}, Latest: {}",
version,
new_version
))
this.error_time = None;
}
})
}
fn download_and_install(&mut self, download_url: &str, cx: &mut Context<Self>) {
let http_client = cx.http_client();
let download_url = download_url.to_string();
let task: Task<Result<(InstallerDir, PathBuf), Error>> = cx.background_spawn(async move {
let installer_dir = InstallerDir::new().await?;
let target_path = Self::target_path(&installer_dir).await?;
// Download the release
download(&download_url, &target_path, http_client).await?;
Ok((installer_dir, target_path))
cx.notify();
});
self.tasks.push(
// Install the new release
// Schedule an auto-check after a 2-minute delay (deferred to run at the
// end of the current frame so the window is fully set up).
cx.defer_in(window, |_this, _window, cx| {
let duration = Duration::from_secs(120);
cx.spawn(async move |this, cx| {
cx.background_executor().timer(duration).await;
this.update(cx, |this, cx| {
this.set_status(AutoUpdateStatus::Installing, cx);
})?;
this.updater.update(cx, |updater, cx| {
updater.check(cx);
});
})
.ok();
})
.detach();
});
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);
})?;
}
Self {
updater,
version,
_subscription: subscription,
error_time: None,
}
}
pub fn idle(&self, cx: &App) -> bool {
let status = self.updater.read(cx).status();
if status == &UpdateStatus::Idle {
return true;
}
if matches!(status, UpdateStatus::Errored(_))
&& self
.error_time
.is_some_and(|t| t.elapsed() >= Duration::from_secs(5))
{
return true;
}
false
}
pub fn status(&self, cx: &App) -> SharedString {
let status = self.updater.read(cx).status();
match status {
UpdateStatus::Idle => "Up to date".into(),
UpdateStatus::Checking => "Checking for updates…".into(),
UpdateStatus::UpToDate => "Up to date".into(),
UpdateStatus::Available(version) => format!("Version {version} available").into(),
UpdateStatus::Downloading { downloaded, total } => {
let total_mb = total.map(|t| t as f64 / 1_048_576.0);
let downloaded_mb = *downloaded as f64 / 1_048_576.0;
match total_mb {
Some(t) => format!("Downloading {downloaded_mb:.1} / {t:.1} MB").into(),
None => format!("Downloading {downloaded_mb:.1} MB").into(),
}
Ok(())
}),
);
}
async fn target_path(installer_dir: &InstallerDir) -> Result<PathBuf, Error> {
let filename = match std::env::consts::OS {
"macos" => anyhow::Ok("Coop.dmg"),
"linux" => Ok("coop.tar.gz"),
"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,
"linux" => install_release_linux(&installer_dir, target_path, cx).await,
"windows" => install_release_windows(target_path).await,
unsupported_os => anyhow::bail!("Not supported: {unsupported_os}"),
}
UpdateStatus::Installing => "Installing update…".into(),
UpdateStatus::Staged(version) => {
format!("Version {version} ready — restart to apply").into()
}
UpdateStatus::Errored(msg) => {
if self
.error_time
.is_some_and(|t| t.elapsed() >= Duration::from_secs(5))
{
"Up to date".into()
} else {
format!("Update failed: {msg}").into()
}
}
}
}
}
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_linux(
temp_dir: &InstallerDir,
downloaded_tar_gz: PathBuf,
cx: &AsyncApp,
) -> Result<(), Error> {
let running_app_path = cx.update(|cx| cx.app_path())?;
// Extract the tar.gz file
let extracted = temp_dir.path().join("coop");
smol::fs::create_dir_all(&extracted)
.await
.context("failed to create directory to extract update")?;
let output = Command::new("tar")
.arg("-xzf")
.arg(&downloaded_tar_gz)
.arg("-C")
.arg(&extracted)
.output()
.await?;
anyhow::ensure!(
output.status.success(),
"failed to extract {:?} to {:?}: {:?}",
downloaded_tar_gz,
extracted,
String::from_utf8_lossy(&output.stderr)
);
// Find the extracted app directory
let mut entries = smol::fs::read_dir(&extracted).await?;
let mut app_dir = None;
use smol::stream::StreamExt;
while let Some(entry) = entries.next().await {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
app_dir = Some(path);
break;
}
}
let from = app_dir.context("No app directory found in archive")?;
// Copy to the current installation directory
let output = Command::new("rsync")
.args(["-av", "--delete"])
.arg(&from)
.arg(
running_app_path
.parent()
.context("No parent directory for app")?,
)
.output()
.await?;
anyhow::ensure!(
output.status.success(),
"failed to copy app from {:?} to {:?}: {:?}",
from,
running_app_path.parent(),
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(())
}
+19
View File
@@ -0,0 +1,19 @@
[package]
name = "browser-signer-proxy"
version = "0.1.0"
edition.workspace = true
description = "Nostr browser signer (NIP-07) proxy using smol async runtime"
license = "MIT"
repository = "https://github.com/nostrdevkit/nostr"
publish = false
[dependencies]
atomic-destructor = "0.2"
event-listener = "5"
nostr.workspace = true
opaquerr = { version = "0.1", features = ["alloc"] }
serde.workspace = true
serde_json.workspace = true
smol.workspace = true
tracing = { version = "0.1", features = ["std"] }
uuid = { version = "1.23", features = ["serde", "v4"] }
+55
View File
@@ -0,0 +1,55 @@
# browser-signer-proxy
Proxy to use Nostr Browser signer ([NIP-07](https://github.com/nostr-protocol/nips/blob/master/07.md)) in native applications.
This is a re-implementation of [`nostr-browser-signer-proxy`](https://github.com/nostrdevkit/nostr/tree/master/signer/nostr-browser-signer-proxy)
using the [`smol`](https://github.com/smol-rs/smol) async runtime instead of tokio.
## Description
This crate provides a local HTTP proxy that communicates with a NIP-07 browser extension
(e.g., Alby, nos2x) running in a browser tab. Native applications can use this proxy to
request public keys, sign events, and perform NIP-04/NIP-44 encryption/decryption through
the browser extension.
The HTTP server is implemented with a minimal, dependency-free approach using `smol::net::TcpListener`
and manual HTTP/1.1 parsing — avoiding heavy HTTP framework dependencies entirely.
## Usage
```rust
use browser_signer_proxy::prelude::*;
async fn example() -> Result<(), Error> {
// Create the proxy with default options (localhost:7400)
let proxy = BrowserSignerProxy::new(BrowserSignerProxyOptions::default());
// Open the proxy URL in a browser
webbrowser::open(&proxy.url())?;
// Start the proxy server
proxy.start().await?;
// Use it as an async Nostr signer
let public_key = proxy.get_public_key_async().await?;
println!("Connected with public key: {public_key}");
Ok(())
}
```
## Differences from the tokio-based version
| Feature | tokio (original) | smol (this crate) |
|---|---|---|
| Async runtime | `tokio` | `smol` |
| HTTP server | `hyper` | `smol::net::TcpListener` + manual HTTP/1.1 |
| Mutex | `tokio::sync::Mutex` | `smol::lock::Mutex` |
| Shutdown signal | `tokio::sync::Notify` | `event_listener::Event` |
| Request-response channel | `tokio::sync::oneshot` | `smol::channel::bounded(1)` |
| Timeout | `tokio::time::timeout` | `smol::future::or` + `smol::Timer` |
| Task spawning | `tokio::spawn` | `smol::spawn` |
## License
This project is distributed under the MIT software license.
+185
View File
@@ -0,0 +1,185 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Coop — Web Signer Proxy</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Nunito:wght@800;900&display=swap" rel="stylesheet">
<style>
:root {
--brand: #F8FF37;
--ink: #111111;
--ink-soft: #333333;
--muted: #666666;
--paper: #FFFFFF;
--edge: rgba(17, 17, 17, 0.14);
--radius-sm: 1rem;
--radius-md: 1.5rem;
--radius-lg: 2.5rem;
--green: #2E8B57;
--red: #D32F2F;
}
* {
box-sizing: border-box;
}
html, body {
margin: 0;
min-height: 100%;
}
body {
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
color: var(--ink);
background: var(--brand);
-webkit-font-smoothing: antialiased;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 2rem;
}
.card {
background: var(--paper);
border-radius: var(--radius-md);
padding: 2.5rem;
max-width: 440px;
width: 100%;
box-shadow: 0 8px 0 rgba(17, 17, 17, 0.12), 0 2px 20px rgba(17, 17, 17, 0.06);
}
.logo {
display: flex;
align-items: center;
gap: 0.6rem;
margin-bottom: 1.75rem;
}
.logo__mark {
width: 2.5rem;
height: 2.5rem;
background: var(--ink);
border-radius: var(--radius-sm);
display: flex;
align-items: center;
justify-content: center;
font-family: "Nunito", system-ui, sans-serif;
font-weight: 900;
font-size: 1.2rem;
color: var(--brand);
letter-spacing: -0.02em;
}
.logo__text {
font-family: "Nunito", system-ui, sans-serif;
font-weight: 900;
font-size: 1.3rem;
letter-spacing: -0.02em;
}
.heading {
font-family: "Nunito", system-ui, sans-serif;
font-weight: 900;
font-size: 1.6rem;
letter-spacing: -0.03em;
line-height: 1.15;
margin: 0 0 0.6rem;
}
.subtitle {
font-size: 0.95rem;
color: var(--ink-soft);
margin: 0 0 1.75rem;
line-height: 1.5;
}
.status {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem 1.25rem;
border-radius: var(--radius-sm);
font-weight: 600;
font-size: 0.95rem;
transition: background 300ms ease, color 300ms ease;
}
.status--checking {
background: rgba(17, 17, 17, 0.05);
color: var(--muted);
}
.status--connected {
background: rgba(46, 139, 87, 0.1);
color: var(--green);
}
.status--error {
background: rgba(211, 47, 47, 0.08);
color: var(--red);
}
.status__dot {
width: 0.7rem;
height: 0.7rem;
border-radius: 50%;
flex-shrink: 0;
}
.status--checking .status__dot {
background: var(--muted);
animation: pulse 1.2s ease-in-out infinite;
}
.status--connected .status__dot {
background: var(--green);
}
.status--error .status__dot {
background: var(--red);
}
@keyframes pulse {
0%, 100% { opacity: 0.4; transform: scale(0.85); }
50% { opacity: 1; transform: scale(1); }
}
.hint {
margin-top: 1.5rem;
padding-top: 1.25rem;
border-top: 1px solid var(--edge);
font-size: 0.8rem;
color: var(--muted);
line-height: 1.5;
}
.hint strong {
color: var(--ink-soft);
}
</style>
</head>
<body>
<div class="card">
<h1 class="heading">Web Signer</h1>
<p class="subtitle">
This page connects the app to your Nostr Web Signer extension so you can sign in and use Coop securely.
</p>
<div id="nip07-status" class="status status--checking">
<div class="status__dot"></div>
<span id="nip07-status-text">Checking extension…</span>
</div>
<div class="hint">
<strong>Keep this tab open</strong> while using the app — it automatically handles sign-in requests in the background.
</div>
</div>
<script src="proxy.js"></script>
</body>
</html>
+156
View File
@@ -0,0 +1,156 @@
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, state) {
const container = document.getElementById('nip07-status');
const textEl = document.getElementById('nip07-status-text');
if (container && textEl) {
container.className = 'status status--' + state;
textEl.textContent = message;
}
}
// 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 — ready', 'connected');
} else {
console.log('NIP-07 extension not found');
updateStatus('No NIP-07 extension found', 'error');
}
// Start polling every 500 ms
setInterval(pollForRequests, 500);
});
+51
View File
@@ -0,0 +1,51 @@
// Copyright (c) 2022-2023 Yuki Kishimoto
// Copyright (c) 2023-2025 Rust Nostr Developers
// Distributed under the MIT software license
//! Error types for the browser signer proxy.
opaquerr::define_kind! {
/// Nostr browser signer proxy error kind.
pub ErrorKind {
/// Nostr protocol error.
Protocol => "nostr protocol error",
/// I/O error.
IO => "I/O error",
/// JSON error.
Json => "JSON error",
/// The operation timed out.
Timeout => "timeout",
/// The operation cannot be completed in the current state.
State => "invalid state",
/// Anything not covered by the stable categories above.
Other => "other error",
}
}
opaquerr::define_error! {
/// Nostr browser signer proxy error.
pub Error(ErrorKind)
from {
nostr::error::Error => ErrorKind::Protocol,
std::io::Error => ErrorKind::IO,
serde_json::Error => ErrorKind::Json,
}
}
impl Error {
pub(crate) fn generic<S>(message: S) -> Self
where
S: Into<String>,
{
Self::new(ErrorKind::Other, message.into())
}
pub(crate) fn timeout() -> Self {
Self::simple(ErrorKind::Timeout)
}
pub(crate) fn shutdown() -> Self {
Self::with_static_message(ErrorKind::State, "server is shutdown")
}
}
+764
View File
@@ -0,0 +1,764 @@
use std::collections::HashMap;
use std::future::Future;
use std::net::{IpAddr, Ipv4Addr, SocketAddr, SocketAddrV4};
use std::pin::Pin;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use atomic_destructor::{AtomicDestroyer, AtomicDestructor};
use event_listener::Event as ShutdownEvent;
use nostr::prelude::*;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize, Serializer};
use serde_json::{Value, json};
use smol::channel;
use smol::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
use smol::lock::Mutex;
use smol::net::{TcpListener, TcpStream};
use uuid::Uuid;
mod error;
pub mod prelude;
pub use self::error::Error;
const DEFAULT_HTML: &str = include_str!("../index.html");
const JS: &str = include_str!("../proxy.js");
type PendingResponseMap = HashMap<Uuid, channel::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 asked for the pending requests
pub last_pending_request: Arc<AtomicU64>,
}
/// 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,
/// Custom HTML page.
// NOTE: not `Option` to move it between threads without reference counter
pub custom_html: &'static str,
}
#[derive(Debug, Clone)]
struct InnerBrowserSignerProxy {
/// Configuration options for the proxy
options: BrowserSignerProxyOptions,
/// Internal state of the proxy including request queues
state: Arc<ProxyState>,
/// Notification trigger for graceful shutdown
shutdown: Arc<ShutdownEvent>,
/// Flag to indicate if the server is shutdown
is_shutdown: Arc<AtomicBool>,
/// Flag 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
self.shutdown.notify(usize::MAX);
}
}
/// 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)),
custom_html: "",
}
}
}
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
}
/// Sets a custom html page.
///
/// The page must include `/proxy.js` script (`<script src="/proxy.js"></script>`)
/// which will handle communication with the server and update the element
/// with id `nip07-proxy-status` with the status.
pub const fn custom_html_page(mut self, custom_html: &'static str) -> Self {
self.custom_html = custom_html;
self
}
}
impl BrowserSignerProxy {
/// Construct a new browser signer proxy
pub fn new(options: BrowserSignerProxyOptions) -> Self {
let state = ProxyState {
outgoing_requests: Mutex::new(Vec::new()),
pending_responses: Mutex::new(HashMap::new()),
last_pending_request: Arc::new(AtomicU64::new(0)),
};
Self {
inner: AtomicDestructor::new(InnerBrowserSignerProxy {
options,
state: Arc::new(state),
shutdown: Arc::new(ShutdownEvent::new()),
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 tab 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 server.
///
/// If this is not called explicitly, the server 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: TcpListener = match TcpListener::bind(self.inner.options.addr).await {
Ok(listener) => listener,
Err(e) => {
// Undo the started flag if binding fails
self.inner.is_started.store(false, Ordering::SeqCst);
return Err(Error::from(e));
}
};
let addr: SocketAddr = self.inner.options.addr;
let state: Arc<ProxyState> = self.inner.state.clone();
let custom_html: &'static str = self.inner.options.custom_html;
let shutdown: Arc<ShutdownEvent> = self.inner.shutdown.clone();
smol::spawn(async move {
tracing::info!("Starting proxy server on {addr}");
loop {
// Race between accepting a new connection and shutdown signal
let shutdown_listener = shutdown.listen();
enum AcceptEvent {
Connection(Result<(TcpStream, SocketAddr), std::io::Error>),
Shutdown,
}
let event = smol::future::or(
async { AcceptEvent::Connection(listener.accept().await) },
async {
shutdown_listener.await;
AcceptEvent::Shutdown
},
)
.await;
match event {
AcceptEvent::Connection(Ok((stream, _))) => {
let state: Arc<ProxyState> = state.clone();
let shutdown: Arc<ShutdownEvent> = shutdown.clone();
smol::spawn(async move {
let shutdown_listener = shutdown.listen();
smol::future::or(
async {
handle_connection(stream, state, custom_html).await;
},
async {
shutdown_listener.await;
tracing::debug!(
"Closing connection, proxy server is shutting down."
);
},
)
.await;
})
.detach();
}
AcceptEvent::Connection(Err(e)) => {
tracing::error!("Failed to accept connection: {e}");
}
AcceptEvent::Shutdown => break,
}
}
tracing::info!("Proxy server shut down.");
})
.detach();
Ok(())
}
#[inline]
async fn store_pending_response(&self, id: Uuid, tx: channel::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 bounded channel of size 1 as a oneshot replacement
let (tx, rx) = channel::bounded::<Result<Value, String>>(1);
// 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 with timeout
let response = race_timeout(self.inner.options.timeout, rx.recv()).await;
match response {
Ok(Ok(res)) => Ok(serde_json::from_value(res)?),
Ok(Err(error)) => Err(Error::generic(error)),
Err(TimeoutError) => Err(Error::timeout()),
}
}
#[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 AsyncGetPublicKey for BrowserSignerProxy {
type Error = Error;
#[inline]
fn get_public_key_async(
&self,
) -> Pin<Box<dyn Future<Output = Result<PublicKey, Self::Error>> + Send + '_>> {
Box::pin(async move { self._get_public_key().await })
}
}
impl AsyncSignEvent for BrowserSignerProxy {
type Error = Error;
#[inline]
fn sign_event_async(
&self,
unsigned: UnsignedEvent,
) -> Pin<Box<dyn Future<Output = Result<Event, Self::Error>> + Send + '_>> {
Box::pin(async move { self._sign_event(unsigned).await })
}
}
impl AsyncNip04 for BrowserSignerProxy {
type Error = Error;
fn nip04_encrypt_async<'a>(
&'a self,
public_key: &'a PublicKey,
content: &'a str,
) -> Pin<Box<dyn Future<Output = Result<String, Self::Error>> + Send + 'a>> {
Box::pin(async move { self._nip04_encrypt(public_key, content).await })
}
fn nip04_decrypt_async<'a>(
&'a self,
public_key: &'a PublicKey,
encrypted_content: &'a str,
) -> Pin<Box<dyn Future<Output = Result<String, Self::Error>> + Send + 'a>> {
Box::pin(async move { self._nip04_decrypt(public_key, encrypted_content).await })
}
}
impl AsyncNip44 for BrowserSignerProxy {
type Error = Error;
fn nip44_encrypt_async<'a>(
&'a self,
public_key: &'a PublicKey,
content: &'a str,
) -> Pin<Box<dyn Future<Output = Result<String, Self::Error>> + Send + 'a>> {
Box::pin(async move { self._nip44_encrypt(public_key, content).await })
}
fn nip44_decrypt_async<'a>(
&'a self,
public_key: &'a PublicKey,
payload: &'a str,
) -> Pin<Box<dyn Future<Output = Result<String, Self::Error>> + Send + 'a>> {
Box::pin(async move { self._nip44_decrypt(public_key, payload).await })
}
}
// ── Minimal HTTP server ──────────────────────────────────────────────────
/// Handle a single HTTP connection.
async fn handle_connection(stream: TcpStream, state: Arc<ProxyState>, custom_html: &'static str) {
let mut reader = BufReader::new(stream);
// Read the request line
let mut request_line = String::new();
if reader.read_line(&mut request_line).await.is_err() {
return;
}
let request_line = request_line.trim_end().to_string();
// Parse method, path, and HTTP version from request line
let parts: Vec<&str> = request_line.split_whitespace().collect();
if parts.len() < 2 {
send_response(&mut reader, 400, "Bad Request", "", "").await;
return;
}
let method = parts[0].to_uppercase();
let path = parts[1].to_string();
// Read headers until empty line
let mut headers = Vec::new();
let mut content_length: usize = 0;
loop {
let mut line = String::new();
if reader.read_line(&mut line).await.is_err() {
return;
}
let line = line.trim_end().to_string();
if line.is_empty() {
break;
}
if let Some(value) = line.strip_prefix("content-length:") {
content_length = value.trim().parse().unwrap_or(0);
} else if let Some(value) = line.strip_prefix("Content-Length:") {
content_length = value.trim().parse().unwrap_or(0);
}
headers.push(line);
}
match (method.as_str(), path.as_str()) {
// Serve the HTML proxy page
("GET", "/") => {
let html = if custom_html.is_empty() {
DEFAULT_HTML
} else {
custom_html
};
send_response(&mut reader, 200, "OK", "text/html", html).await;
}
// Serve the JS proxy script
("GET", "/proxy.js") => {
send_response(&mut reader, 200, "OK", "application/javascript", JS).await;
}
// Browser polls this endpoint to get pending requests
("GET", "/api/pending") => {
state
.last_pending_request
.store(current_time(), Ordering::SeqCst);
let mut outgoing = state.outgoing_requests.lock().await;
let requests = Requests::new(&outgoing);
let json = match serde_json::to_string(&requests) {
Ok(j) => j,
Err(e) => {
tracing::error!("Failed to serialize pending requests: {e}");
send_response(&mut reader, 500, "Internal Server Error", "", "").await;
return;
}
};
tracing::debug!("Sending {} pending requests to browser", requests.len());
// Clear the outgoing requests after sending them
outgoing.clear();
send_response_cors_json(&mut reader, 200, "OK", &json).await;
}
// Receive response from browser extension
("POST", "/api/response") => {
let mut body_bytes = vec![0u8; content_length];
if content_length > 0 && reader.read_exact(&mut body_bytes).await.is_err() {
send_response(&mut reader, 400, "Bad Request", "", "").await;
return;
}
let message: Message = match serde_json::from_slice(&body_bytes) {
Ok(json) => json,
Err(e) => {
tracing::error!("Failed to parse response body: {e}");
send_response(&mut reader, 400, "Invalid JSON", "", "").await;
return;
}
};
tracing::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) => {
// Use try_send since we already hold the lock
let _ = sender.try_send(message.into_result());
tracing::info!("Forwarded response for request {id}");
}
None => tracing::warn!("No pending request found for {id}"),
}
send_response_cors(&mut reader, 200, "OK", "text/plain", "OK").await;
}
// CORS preflight
("OPTIONS", _) => {
let response = "HTTP/1.1 200 OK\r\n\
Access-Control-Allow-Origin: *\r\n\
Access-Control-Allow-Methods: GET, POST, OPTIONS\r\n\
Access-Control-Allow-Headers: Content-Type\r\n\
Content-Length: 0\r\n\
Connection: close\r\n\
\r\n";
let _ = reader.get_mut().write_all(response.as_bytes()).await;
let _ = reader.get_mut().flush().await;
}
// 404 - not found
_ => {
send_response(&mut reader, 404, "Not Found", "", "").await;
}
}
}
/// Write an HTTP response to the stream.
async fn send_response(
stream: &mut (impl AsyncWriteExt + Unpin),
status: u16,
status_text: &str,
content_type: &str,
body: &str,
) {
let mut response = format!("HTTP/1.1 {status} {status_text}\r\n");
if !content_type.is_empty() {
response.push_str(&format!("Content-Type: {content_type}\r\n"));
}
response.push_str(&format!("Content-Length: {}\r\n", body.len()));
response.push_str("Access-Control-Allow-Origin: *\r\n");
response.push_str("Connection: close\r\n");
response.push_str("\r\n");
response.push_str(body);
let _ = stream.write_all(response.as_bytes()).await;
let _ = stream.flush().await;
}
/// Write a response with CORS headers and JSON content type.
async fn send_response_cors_json(
stream: &mut (impl AsyncWriteExt + Unpin),
status: u16,
status_text: &str,
body: &str,
) {
let mut response = format!("HTTP/1.1 {status} {status_text}\r\n");
response.push_str("Content-Type: application/json\r\n");
response.push_str(&format!("Content-Length: {}\r\n", body.len()));
response.push_str("Access-Control-Allow-Origin: *\r\n");
response.push_str("Connection: close\r\n");
response.push_str("\r\n");
response.push_str(body);
let _ = stream.write_all(response.as_bytes()).await;
let _ = stream.flush().await;
}
/// Write a response with CORS headers.
async fn send_response_cors(
stream: &mut (impl AsyncWriteExt + Unpin),
status: u16,
status_text: &str,
content_type: &str,
body: &str,
) {
let mut response = format!("HTTP/1.1 {status} {status_text}\r\n");
if !content_type.is_empty() {
response.push_str(&format!("Content-Type: {content_type}\r\n"));
}
response.push_str(&format!("Content-Length: {}\r\n", body.len()));
response.push_str("Access-Control-Allow-Origin: *\r\n");
response.push_str("Connection: close\r\n");
response.push_str("\r\n");
response.push_str(body);
let _ = stream.write_all(response.as_bytes()).await;
let _ = stream.flush().await;
}
// ── Timeout helper ───────────────────────────────────────────────────────
/// An error indicating that an operation timed out.
#[derive(Debug)]
struct TimeoutError;
/// Races a channel receive against a duration.
///
/// Returns the channel value on success, or [`TimeoutError`] if the duration
/// elapses first or the channel is closed.
async fn race_timeout<T>(
duration: Duration,
recv: impl Future<Output = Result<T, channel::RecvError>>,
) -> Result<T, TimeoutError> {
enum Event<T> {
Value(T),
ChannelClosed,
Timeout,
}
let event = smol::future::or(
async {
match recv.await {
Ok(value) => Event::Value(value),
Err(_) => Event::ChannelClosed,
}
},
async {
smol::Timer::after(duration).await;
Event::Timeout
},
)
.await;
match event {
Event::Value(value) => Ok(value),
Event::ChannelClosed | Event::Timeout => Err(TimeoutError),
}
}
// ── Utility ──────────────────────────────────────────────────────────────
/// 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()
}
@@ -0,0 +1,14 @@
// Copyright (c) 2022-2023 Yuki Kishimoto
// Copyright (c) 2023-2025 Rust Nostr Developers
// Distributed under the MIT software license
//! Prelude
#![allow(unknown_lints)]
#![allow(ambiguous_glob_reexports)]
#![doc(hidden)]
pub use nostr::prelude::*;
pub use crate::error::{Error, ErrorKind};
pub use crate::*;
+71 -56
View File
@@ -1,5 +1,5 @@
use std::cmp::Reverse;
use std::collections::{BTreeSet, HashMap, HashSet};
use std::collections::{BTreeSet, HashMap, HashSet, hash_map};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, LazyLock, RwLock};
@@ -80,6 +80,9 @@ pub struct ChatRegistry {
/// Chat rooms
rooms: Vec<Entity<Room>>,
/// O(1) room lookup by room ID
room_index: HashMap<u64, Entity<Room>>,
/// Events that failed to unwrap for any reason
trash: Entity<BTreeSet<FailedMessage>>,
@@ -170,6 +173,7 @@ impl ChatRegistry {
Self {
rooms: vec![],
room_index: HashMap::new(),
trash: cx.new(|_| BTreeSet::default()),
seen: Arc::new(RwLock::new(HashMap::default())),
event_map: Arc::new(RwLock::new(HashMap::default())),
@@ -361,7 +365,8 @@ impl ChatRegistry {
.query(filter)
.await
.unwrap_or_default()
.first_owned()
.into_iter()
.next()
.is_some();
if !found {
@@ -393,7 +398,8 @@ impl ChatRegistry {
.database()
.query(filter)
.await?
.first_owned()
.into_iter()
.next()
.ok_or(anyhow::anyhow!("No inbox relays found"))?;
let relays: Vec<RelayUrl> = nip17::extract_relay_list(&event).collect();
@@ -436,12 +442,9 @@ impl ChatRegistry {
self.tracking.load(Ordering::Acquire)
}
/// Get a weak reference to a room by its ID.
pub fn room(&self, id: &u64, cx: &App) -> Option<WeakEntity<Room>> {
self.rooms
.iter()
.find(|this| &this.read(cx).id == id)
.map(|this| this.downgrade())
/// Get a weak reference to a room by its ID
pub fn room(&self, id: &u64, _cx: &App) -> Option<WeakEntity<Room>> {
self.room_index.get(id).map(|room| room.downgrade())
}
/// Get all rooms based on the filter.
@@ -511,7 +514,11 @@ impl ChatRegistry {
};
let room: Room = room.into().organize(&public_key);
self.rooms.insert(0, cx.new(|_| room));
let room_id = room.id;
let entity = cx.new(|_| room);
self.room_index.insert(room_id, entity.clone());
self.rooms.insert(0, entity);
cx.emit(ChatEvent::Ping);
cx.notify();
@@ -524,9 +531,11 @@ impl ChatRegistry {
// Get the room's ID.
let id = room.read(cx).id;
// If the room is new, add it to the registry.
if !self.rooms.iter().any(|r| r.read(cx).id == id) {
self.rooms.insert(0, room.to_owned());
// If the room is new, add it to the registry and index.
if let hash_map::Entry::Vacant(e) = self.room_index.entry(id) {
let entity = room.to_owned();
e.insert(entity.clone());
self.rooms.insert(0, entity);
}
// Emit the open room event deferred to avoid re-entrant reads
@@ -537,17 +546,23 @@ impl ChatRegistry {
/// Close a room.
pub fn close_room(&mut self, id: u64, window: &mut Window, cx: &mut Context<Self>) {
if self.rooms.iter().any(|r| r.read(cx).id == id) {
if self.room_index.contains_key(&id) {
self.room_index.remove(&id);
self.rooms.retain(|r| r.read(cx).id != id);
cx.defer_in(window, move |_this, _window, cx| {
cx.emit(ChatEvent::CloseRoom(id));
});
}
}
/// Sort rooms by their created at.
/// Sort rooms by their created at. Only notifies if order changed.
pub fn sort(&mut self, cx: &mut Context<Self>) {
let before: Vec<_> = self.rooms.iter().map(|ev| ev.read(cx).id).collect();
self.rooms.sort_by_key(|ev| Reverse(ev.read(cx).created_at));
cx.notify();
let after: Vec<_> = self.rooms.iter().map(|ev| ev.read(cx).id).collect();
if before != after {
cx.notify();
}
}
/// Finding rooms based on a query.
@@ -574,6 +589,7 @@ impl ChatRegistry {
/// Reset the registry.
pub fn reset(&mut self, cx: &mut Context<Self>) {
self.rooms.clear();
self.room_index.clear();
self.trash.update(cx, |this, cx| {
this.clear();
cx.notify();
@@ -601,7 +617,9 @@ impl ChatRegistry {
});
} else {
let new_room_id = new_room.id;
self.rooms.push(cx.new(|_| new_room));
let entity = cx.new(|_| new_room);
self.room_index.insert(new_room_id, entity.clone());
self.rooms.push(entity);
let new_index = self.rooms.len();
room_map.insert(new_room_id, new_index);
@@ -611,13 +629,7 @@ impl ChatRegistry {
/// Load all rooms from the database.
pub fn get_rooms(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let Some(public_key) = nostr.read(cx).current_user() else {
return;
};
let task = self.get_rooms_from_database(public_key, cx);
let task = self.get_rooms_task(cx);
self.tasks.push(cx.spawn(async move |this, cx| {
match task.await {
@@ -639,61 +651,64 @@ impl ChatRegistry {
}
/// Create a task to load rooms from the database
fn get_rooms_from_database(
&self,
public_key: PublicKey,
cx: &App,
) -> Task<Result<HashSet<Room>, Error>> {
fn get_rooms_task(&self, cx: &App) -> Task<Result<HashSet<Room>, Error>> {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
cx.background_spawn(async move {
let contacts = client
let public_key = signer.get_public_key_async().await?;
// Query the latest contact list (previously `NostrDatabaseExt::contacts_public_keys`)
let filter = Filter::new()
.author(public_key)
.kind(Kind::ContactList)
.limit(1);
let contacts: HashSet<PublicKey> = client
.database()
.contacts_public_keys(public_key)
.query(filter)
.await
.unwrap_or_default()
.into_iter()
.next()
.map(|event| event.tags.public_keys().collect())
.unwrap_or_default();
// Query all cached rumor events (works with both old and new cache formats)
let filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.custom_tag(SingleLetterTag::lowercase(Alphabet::K), "14");
let events = client.database().query(filter).await?;
.custom_tag(SingleLetterTag::LOWERCASE_K, "14");
let mut rooms: HashSet<Room> = HashSet::new();
let events = client.database().query(filter).await?;
let mut grouped: HashMap<u64, Vec<UnsignedEvent>> = HashMap::new();
for raw in events.into_iter() {
if let Ok(rumor) = UnsignedEvent::from_json(&raw.content)
&& rumor.tags.public_keys().peekable().peek().is_some()
&& rumor.tags.public_keys().next().is_some()
{
if rumor.pubkey != public_key
&& !rumor.tags.public_keys().any(|k| k == public_key)
{
continue;
}
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 mut rooms = HashSet::with_capacity(grouped.len());
// Always use the latest message
let Some(latest) = messages.first() else {
continue;
};
for (_id, messages) in grouped.into_iter() {
let latest = messages.iter().max_by_key(|m| m.created_at).unwrap();
let room = Room::from(latest).organize(&public_key);
// Construct the room from the latest message.
//
// Call `.organize` to ensure the current user is at the end of the list.
let mut room = Room::from(latest).organize(&public_key);
// Check if the user has responded to the room
let user_sent = messages.iter().any(|m| m.pubkey == public_key);
// Check if public keys are from the user's contacts
let is_contact = room.members.iter().any(|k| contacts.contains(k));
// Set the room's kind based on status
if user_sent || is_contact {
room = room.kind(RoomKind::Ongoing);
}
let room = if user_sent || is_contact {
room.kind(RoomKind::Ongoing)
} else {
room
};
rooms.insert(room);
}
@@ -713,7 +728,7 @@ impl ChatRegistry {
return;
};
match self.rooms.iter().find(|e| e.read(cx).id == message.room) {
match self.room_index.get(&message.room).cloned() {
Some(room) => {
room.update(cx, |this, cx| {
if this.kind == RoomKind::Request && message.rumor.pubkey == public_key {
@@ -825,7 +840,7 @@ async fn set_rumor(client: &Client, id: EventId, rumor: &UnsignedEvent) -> Resul
async fn get_rumor(client: &Client, gift_wrap: EventId) -> Result<UnsignedEvent, Error> {
let filter = Filter::new().identifier(gift_wrap).limit(1);
if let Some(event) = client.database().query(filter).await?.first_owned() {
if let Some(event) = client.database().query(filter).await?.into_iter().next() {
UnsignedEvent::from_json(event.content).map_err(|e| anyhow!(e))
} else {
Err(anyhow!("Event is not cached yet."))
+22 -14
View File
@@ -1,11 +1,11 @@
use std::cmp::Ordering;
use std::hash::{Hash, Hasher};
use instant::Duration;
use anyhow::{Error, anyhow};
use common::EventExt;
use device::DeviceRegistry;
use gpui::{App, AppContext, Context, EventEmitter, SharedString, Task};
use instant::Duration;
use itertools::Itertools;
use nostr_sdk::prelude::*;
use person::{Person, PersonRegistry};
@@ -271,8 +271,8 @@ impl Room {
}
/// Returns the members of the room
pub fn members(&self) -> Vec<PublicKey> {
self.members.clone()
pub fn members(&self) -> &[PublicKey] {
&self.members
}
/// Checks if the room has more than two members (group)
@@ -356,7 +356,7 @@ impl Room {
pub fn connect(&self, cx: &App) -> Task<Result<(), Error>> {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let members = self.members();
let members = self.members().to_vec();
cx.background_spawn(async move {
let opts = SubscribeAutoCloseOptions::default()
@@ -403,7 +403,7 @@ impl Room {
cx.background_spawn(async move {
let filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.custom_tag(SingleLetterTag::lowercase(Alphabet::R), room_id);
.custom_tag(SingleLetterTag::LOWERCASE_R, room_id);
let messages = client
.database()
@@ -419,12 +419,23 @@ impl Room {
}
// Construct a rumor event for direct message
pub fn rumor<S, I>(&self, content: S, replies: I, cx: &App) -> Option<UnsignedEvent>
pub fn rumor<S, I>(
&self,
content: S,
replies: I,
reaction: bool,
cx: &App,
) -> Option<UnsignedEvent>
where
S: Into<String>,
I: IntoIterator<Item = EventId>,
{
let kind = Kind::PrivateDirectMessage;
let kind = if reaction {
Kind::Reaction
} else {
Kind::PrivateDirectMessage
};
let content: String = content.into();
let replies: Vec<EventId> = replies.into_iter().collect();
@@ -450,13 +461,10 @@ impl Room {
// Add all receiver tags (no intermediate allocation)
for public_key in self.members.iter().filter(|pk| *pk != &sender) {
let member = persons.read(cx).get(public_key, cx);
tags.push(
Nip01Tag::PublicKey {
public_key: member.public_key(),
relay_hint: member.messaging_relay_hint(),
}
.to_tag(),
);
tags.push(Tag::from(Nip01Tag::PublicKey {
public_key: member.public_key(),
relay_hint: member.messaging_relay_hint(),
}));
}
// Construct a direct message rumor event
+1
View File
@@ -25,4 +25,5 @@ serde.workspace = true
linkify = "0.10.0"
pulldown-cmark = "0.13.1"
regex = "1"
+246 -70
View File
@@ -1,5 +1,5 @@
use std::collections::{BTreeMap, HashSet};
use std::sync::{Arc, RwLock};
use std::collections::{BTreeMap, HashMap, HashSet};
use std::sync::{Arc, LazyLock, RwLock};
pub use actions::*;
use anyhow::{Context as AnyhowContext, Error};
@@ -11,12 +11,14 @@ use gpui::{
AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle,
Focusable, InteractiveElement, IntoElement, ListAlignment, ListOffset, ListState, MouseButton,
ObjectFit, ParentElement, PathPromptOptions, Render, SharedString, SharedUri,
StatefulInteractiveElement, Styled, StyledImage, Subscription, Task, WeakEntity, Window, div,
img, list, px, red, relative, svg, white,
StatefulInteractiveElement, Styled, StyledImage, Subscription, SystemNotification,
SystemNotificationAction, Task, WeakEntity, Window, div, img, list, px, red, relative, svg,
white,
};
use itertools::Itertools;
use nostr_sdk::prelude::*;
use person::{Person, PersonRegistry};
use regex::Regex;
use settings::{AppSettings, SignerKind};
use smallvec::{SmallVec, smallvec};
use state::{NostrRegistry, upload};
@@ -35,6 +37,14 @@ use ui::{
use crate::text::RenderedText;
const REACTION_EMOJIS: &[&str] = &["👍", "👎", "😄", "🎉", "😕", "❤️", "🚀", "👀"];
const COMPACT_REACTION_EMOJIS: &[&str] = &["👍", "❤️", "👀"];
/// Regex matching strings that consist entirely of emoji characters,
/// zero-width joiners, variation selectors, and keycap combiners.
static EMOJI_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^[\p{Emoji}\u{200D}\u{FE0F}\u{20E3}]+$").unwrap());
mod actions;
mod text;
@@ -56,6 +66,12 @@ pub struct ChatPanel {
/// All messages (sorted by created_at)
messages: Vec<Message>,
/// O(1) message lookup by EventId
message_index: HashMap<EventId, usize>,
/// All reactions
reactions: BTreeMap<EventId, Vec<(SharedString, PublicKey)>>,
/// Mapping message ids to their rendered texts
rendered_texts_by_id: BTreeMap<EventId, RenderedText>,
@@ -160,6 +176,8 @@ impl ChatPanel {
focus_handle: cx.focus_handle(),
id,
messages,
message_index: HashMap::new(),
reactions: BTreeMap::new(),
room,
list_state,
input,
@@ -224,23 +242,29 @@ impl ChatPanel {
while let Ok(status) = rx.recv_async().await {
{
let mut map = reports.write().unwrap();
for reports in map.values_mut() {
for report in reports.iter_mut() {
let status_id = match &*status {
SendStatus::Ok { id, .. } => *id,
SendStatus::Failed { id, .. } => *id,
};
// Find the matching report and update it (exit early on first match)
'outer: for reports_list in map.values_mut() {
for report in reports_list.iter_mut() {
let Some(output) = report.output.as_mut() else {
continue;
};
if *output.id() != status_id {
continue;
}
match &*status {
SendStatus::Ok { id, relay } => {
if output.id() == id {
output.success.insert(relay.clone(), EventSendStatus::Sent);
}
SendStatus::Ok { relay, .. } => {
output.success.insert(relay.clone(), EventSendStatus::Sent);
}
SendStatus::Failed { id, relay, message } => {
if output.id() == id {
output.failed.insert(relay.clone(), message.clone());
}
SendStatus::Failed { relay, message, .. } => {
output.failed.insert(relay.clone(), message.clone());
}
}
break 'outer;
}
}
}
@@ -252,26 +276,44 @@ impl ChatPanel {
/// Subscribe to room events
fn subscribe_room_events(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if let Some(room) = self.room.upgrade() {
self.subscriptions.push(cx.subscribe_in(
&room,
window,
move |this, _room, event, window, cx| {
match event {
RoomEvent::Incoming(message) => {
let Some(room) = self.room.upgrade() else {
return;
};
self.subscriptions.push(cx.subscribe_in(
&room,
window,
move |this, _room, event, window, cx| {
match event {
RoomEvent::Incoming(message) => {
if message.rumor.kind == Kind::Reaction {
this.insert_reaction(&message.rumor, cx);
} else {
this.insert_message(message, false, cx);
if !window.is_window_active() {
cx.show_system_notification(SystemNotification {
tag: "message".into(),
title: "New Message".into(),
body: "You have a new message.".into(),
actions: vec![SystemNotificationAction {
id: "open".into(),
label: "Open".into(),
}],
});
}
}
RoomEvent::Reload => {
// Defer to avoid re-entrant read on Room while
// emit_refresh holds a write lock (via refresh_rooms).
cx.defer_in(window, |this, window, cx| {
this.get_messages(window, cx);
});
}
};
},
));
}
}
RoomEvent::Reload => {
// Defer to avoid re-entrant read on Room while
// emit_refresh holds a write lock (via refresh_rooms).
cx.defer_in(window, |this, window, cx| {
this.get_messages(window, cx);
});
}
};
},
));
}
/// Load all messages belonging to this room
@@ -331,24 +373,60 @@ impl ChatPanel {
// Get the message which includes all attachments
let content = self.get_input_value(cx);
// Get the replies to this message
let replies: Vec<EventId> = self.replies_to.read(cx).iter().copied().collect();
// Return if message is empty
if content.trim().is_empty() {
window.push_notification("Cannot send an empty message", cx);
return;
}
self.send_message(&content, window, cx);
// If replying to exactly one message with only a valid emoji,
// send as a reaction instead of a text message
if replies.len() == 1 && EMOJI_RE.is_match(&content) && self.attachments.read(cx).is_empty()
{
for reply in &replies {
self.send_reaction(&content, reply, window, cx);
}
self.clear(window, cx);
return;
}
self.send_message(&content, replies, false, window, cx);
}
fn send_reaction(
&mut self,
emoji: &str,
target: &EventId,
window: &mut Window,
cx: &mut Context<Self>,
) {
// Return if emoji is empty
if emoji.trim().is_empty() {
window.push_notification("Cannot send an empty reaction", cx);
return;
}
self.send_message(emoji, vec![*target], true, window, cx);
}
/// Send a message to all members of the chat
fn send_message(&mut self, value: &str, window: &mut Window, cx: &mut Context<Self>) {
fn send_message(
&mut self,
value: &str,
replies: Vec<EventId>,
reaction: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
if value.trim().is_empty() {
window.push_notification("Cannot send an empty message", cx);
return;
}
let room = self.room.clone();
let replies: Vec<EventId> = self.replies_to.read(cx).iter().copied().collect();
let content = value.to_string();
let sent_ids = self.sent_ids.clone();
@@ -356,8 +434,10 @@ impl ChatPanel {
let Some(room_entity) = room.upgrade() else {
return;
};
// Create rumor and send task
let (rumor, send_task) = match room_entity.read_with(cx, |room, cx| {
let rumor = room.rumor(content.clone(), replies, cx)?;
let rumor = room.rumor(content.clone(), replies.clone(), reaction, cx)?;
let send_task = room.send(rumor.clone(), cx)?;
Some((rumor, send_task))
}) {
@@ -371,9 +451,15 @@ impl ChatPanel {
let id = rumor.id.expect("rumor must have an id");
// Insert optimistic message and clear input
self.insert_message(&rumor, true, cx);
if rumor.kind != Kind::Reaction {
self.insert_message(&rumor, true, cx);
self.clear(window, cx);
} else {
self.insert_reaction(&rumor, cx);
}
// Update reports
self.insert_reports(id, vec![], cx);
self.clear(window, cx);
// Spawn a single task to await the send and update reports
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
@@ -428,6 +514,10 @@ impl ChatPanel {
if let Err(pos) = self.messages.binary_search(&msg) {
self.messages.insert(pos, msg);
// Rebuild message index after insertion (indices from pos to end shift)
for (i, message) in self.messages.iter().enumerate().skip(pos) {
self.message_index.insert(message.id, i);
}
self.list_state.splice(old_len..old_len, 1);
if scroll {
@@ -444,23 +534,56 @@ impl ChatPanel {
/// Convert and insert a vector of nostr events into the chat panel
fn insert_messages(&mut self, events: &[UnsignedEvent], cx: &mut Context<Self>) {
for event in events.iter() {
if event.kind == Kind::Reaction {
self.insert_reaction(event, cx);
continue;
}
// Bulk inserting messages, so no need to scroll to the latest message
self.insert_message(event, false, cx);
}
}
/// Check if a message has any reports
fn has_reports(&self, id: &EventId, _cx: &App) -> bool {
self.reports_by_id.read().unwrap().get(id).is_some()
/// Insert a reaction into the chat panel
fn insert_reaction(&mut self, event: &UnsignedEvent, cx: &mut Context<Self>) {
if event.kind != Kind::Reaction {
return;
}
for id in event.tags.event_ids() {
self.reactions
.entry(id)
.or_default()
.push((SharedString::from(&event.content), event.pubkey));
}
cx.notify();
}
fn sent_reports(&self, id: &EventId, _cx: &App) -> Option<Vec<SendReport>> {
/// Check if a message has any reports
fn has_reports(&self, id: &EventId) -> bool {
self.reports_by_id.read().unwrap().contains_key(id)
}
/// Clone reports for a message (used for modal display, not called during render)
fn sent_reports(&self, id: &EventId) -> Option<Vec<SendReport>> {
self.reports_by_id.read().unwrap().get(id).cloned()
}
/// Get a message by its ID
/// Get a message by its ID (O(1) lookup)
fn message(&self, id: &EventId) -> Option<&Message> {
self.messages.iter().find(|msg| &msg.id == id)
self.message_index
.get(id)
.and_then(|&ix| self.messages.get(ix))
}
/// Get a reaction by its target ID (returns reference, no allocation)
fn reaction(&self, id: &EventId) -> &[(SharedString, PublicKey)] {
self.reactions.get(id).map(|v| v.as_slice()).unwrap_or(&[])
}
/// Check if a message has any reactions
fn has_reaction(&self, id: &EventId) -> bool {
self.reactions.contains_key(id)
}
/// Scroll to a message by its ID
@@ -575,7 +698,10 @@ impl ChatPanel {
fn on_command(&mut self, command: &Command, window: &mut Window, cx: &mut Context<Self>) {
match command {
Command::Insert(content) => {
self.send_message(content, window, cx);
self.input.update(cx, |this, cx| {
let new_value = format!("{} {}", this.value(), content);
this.set_value(new_value, window, cx);
});
}
Command::ChangeSubject(subject) => {
if self
@@ -834,7 +960,8 @@ impl ChatPanel {
let replies = message.replies_to.as_slice();
let has_replies = !replies.is_empty();
let has_reports = self.has_reports(&id, cx);
let has_reactions = self.has_reaction(&id);
let has_reports = self.has_reports(&id);
// Hide avatar setting
let hide_avatar = AppSettings::get_hide_avatar(cx);
@@ -879,12 +1006,7 @@ impl ChatPanel {
.gap_2()
.text_sm()
.text_color(cx.theme().text_placeholder)
.child(
div()
.font_semibold()
.text_color(cx.theme().text)
.child(author.name()),
)
.child(div().font_semibold().child(author.name()))
.child(message.created_at.to_human_time())
.when(has_reports, |this| {
this.child(self.render_sent_reports(&id, cx))
@@ -895,7 +1017,10 @@ impl ChatPanel {
this.children(self.render_message_replies(replies, cx))
})
.child(rendered_text)
.child(self.render_media(&message.media, cx)),
.child(self.render_media(&message.media, cx))
.when(has_reactions, |this| {
this.child(self.render_reactions(&id, cx))
}),
),
)
.child(
@@ -990,13 +1115,9 @@ impl ChatPanel {
.w_full()
.px_2()
.border_l_2()
.border_color(cx.theme().element_selected)
.border_color(cx.theme().element_active)
.text_sm()
.child(
div()
.text_color(cx.theme().text_accent)
.child(author.name()),
)
.child(div().font_semibold().child(author.name()))
.child(
div()
.w_full()
@@ -1017,8 +1138,45 @@ impl ChatPanel {
items
}
fn render_reactions(&self, id: &EventId, cx: &App) -> impl IntoElement {
let current_user = NostrRegistry::global(cx).read(cx).current_user();
let reactions = self.reaction(id);
// Group reactions by emoji and collect authors for each
let mut grouped: BTreeMap<SharedString, Vec<PublicKey>> = BTreeMap::new();
for (emoji, author) in reactions {
grouped.entry(emoji.clone()).or_default().push(*author);
}
h_flex()
.mt_2()
.gap_1()
.children(grouped.into_iter().map(|(emoji, authors)| {
let count = authors.len();
let has_reacted = current_user
.map(|pk| authors.contains(&pk))
.unwrap_or(false);
h_flex()
.gap_2()
.py_0p5()
.px_1()
.rounded(cx.theme().radius)
.text_xs()
.border_1()
.when(has_reacted, |this| {
this.text_color(cx.theme().secondary_foreground)
.bg(cx.theme().secondary_background)
.border_color(cx.theme().secondary_active)
})
.when(!has_reacted, |this| this.border_color(cx.theme().border))
.child(emoji)
.child(SharedString::from(count.to_string()))
}))
}
fn render_sent_reports(&self, id: &EventId, cx: &App) -> impl IntoElement {
let reports = self.sent_reports(id, cx);
let reports = self.sent_reports(id);
let pending = reports
.as_ref()
@@ -1192,6 +1350,29 @@ impl ChatPanel {
.border_1()
.border_color(cx.theme().border)
.bg(cx.theme().background)
.children({
let mut items = vec![];
for emoji in COMPACT_REACTION_EMOJIS {
items.push(
Button::new(*emoji)
.label(*emoji)
.tooltip(*emoji)
.small()
.ghost()
.on_click({
let emoji = *emoji;
let id = *id;
cx.listener(move |this, _event, window, cx| {
this.send_reaction(emoji, &id, window, cx);
})
}),
);
}
items
})
.child(div().flex_shrink_0().h_4().w_px().bg(cx.theme().border))
.child(
Button::new("reply")
.icon(IconName::Reply)
@@ -1305,7 +1486,7 @@ impl ChatPanel {
.gap_1()
.text_xs()
.text_color(cx.theme().text_muted)
.child(SharedString::from("Replying to:"))
.child("Replying to:")
.child(
div()
.text_color(cx.theme().text_accent)
@@ -1402,15 +1583,10 @@ impl ChatPanel {
.ghost()
.large()
.dropdown_menu_with_anchor(gpui::Anchor::BottomLeft, move |this, _window, _cx| {
this.horizontal()
.menu("👍", Box::new(Command::Insert("👍")))
.menu("👎", Box::new(Command::Insert("👎")))
.menu("😄", Box::new(Command::Insert("😄")))
.menu("🎉", Box::new(Command::Insert("🎉")))
.menu("😕", Box::new(Command::Insert("😕")))
.menu("❤️", Box::new(Command::Insert("❤️")))
.menu("🚀", Box::new(Command::Insert("🚀")))
.menu("👀", Box::new(Command::Insert("👀")))
let menu = this.horizontal();
REACTION_EMOJIS.iter().fold(menu, |this, emoji| {
this.menu(*emoji, Box::new(Command::Insert(emoji)))
})
})
}
}
+2 -2
View File
@@ -4,13 +4,13 @@ use std::path::PathBuf;
use std::rc::Rc;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use instant::Duration;
use anyhow::{Context as AnyhowContext, Error, anyhow};
use gpui::{
App, AppContext, Context, Entity, EventEmitter, Global, IntoElement, ParentElement,
SharedString, Styled, Subscription, Task, Window, div, relative,
};
use instant::Duration;
use nostr_sdk::prelude::*;
use person::PersonRegistry;
use settings::AppSettings;
@@ -414,7 +414,7 @@ impl DeviceRegistry {
.pubkey(app_pubkey)
.limit(1);
match client.database().query(filter).await?.first_owned() {
match client.database().query(filter).await?.into_iter().next() {
// Found an approval event
Some(event) => Ok(Some(event)),
// No approval event found, construct a request event
-1
View File
@@ -15,4 +15,3 @@ anyhow.workspace = true
smallvec.workspace = true
flume.workspace = true
log.workspace = true
urlencoding = "2.1.3"
+1 -1
View File
@@ -1,10 +1,10 @@
use std::collections::{HashMap, HashSet};
use std::sync::RwLock;
use instant::Duration;
use anyhow::{Error, anyhow};
use common::EventExt;
use gpui::{App, AppContext, Context, Entity, Global, Task, Window};
use instant::Duration;
use nostr_sdk::prelude::*;
use smallvec::{SmallVec, smallvec};
use state::{Announcement, BOOTSTRAP_RELAYS, NostrRegistry, TIMEOUT};
+1 -9
View File
@@ -5,8 +5,6 @@ use gpui::SharedString;
use nostr_sdk::prelude::*;
use state::Announcement;
const IMAGE_RESIZER: &str = "https://wsrv.nl";
/// Person
#[derive(Debug, Clone)]
pub struct Person {
@@ -111,13 +109,7 @@ impl Person {
.picture
.as_ref()
.filter(|picture| !picture.is_empty())
.map(|picture| {
let encoded_picture = urlencoding::encode(picture);
let url = format!(
"{IMAGE_RESIZER}/?url={encoded_picture}&w=100&h=100&fit=cover&mask=circle&n=-1"
);
url.into()
})
.map(|picture| picture.into())
.unwrap_or_else(|| "brand/avatar.png".into())
}
+1
View File
@@ -28,6 +28,7 @@ mime_guess = "2.0.4"
nostr-memory.workspace = true
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
browser-signer-proxy = { path = "../browser-signer-proxy" }
nostr-lmdb.workspace = true
smol.workspace = true
gpui_tokio.workspace = true
+94 -7
View File
@@ -1,8 +1,11 @@
use std::collections::HashMap;
use anyhow::{Error, anyhow};
#[cfg(not(target_arch = "wasm32"))]
use browser_signer_proxy::prelude::*;
use common::config_dir;
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task, Window};
use gpui_tokio::Tokio;
use instant::Duration;
use nostr_connect::prelude::*;
use nostr_gossip_memory::prelude::*;
@@ -24,7 +27,7 @@ pub use nip4e::*;
pub use nip05::*;
pub use signer::{CoopAuthUrlHandler, UniversalSigner};
pub fn init(window: &mut Window, cx: &mut App) {
pub fn init(window: &mut Window, cx: &mut App, cli_key: Option<SecretKey>) {
// 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`.
@@ -37,7 +40,7 @@ pub fn init(window: &mut Window, cx: &mut App) {
#[cfg(not(target_arch = "wasm32"))]
gpui_tokio::init(cx);
NostrRegistry::set_global(cx.new(|cx| NostrRegistry::new(window, cx)), cx);
NostrRegistry::set_global(cx.new(|cx| NostrRegistry::new(window, cx, cli_key)), cx);
}
struct GlobalNostrRegistry(Entity<NostrRegistry>);
@@ -58,16 +61,16 @@ pub enum StateEvent {
}
impl StateEvent {
pub fn signer_changed(&self) -> bool {
matches!(self, StateEvent::SignerChanged)
}
pub fn error<T>(error: T) -> Self
where
T: Into<String>,
{
Self::Error(error.into())
}
pub fn signer_changed(&self) -> bool {
matches!(self, StateEvent::SignerChanged)
}
}
/// Nostr Registry
@@ -100,7 +103,7 @@ impl NostrRegistry {
}
/// Create a new nostr instance
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
fn new(window: &mut Window, cx: &mut Context<Self>, cli_key: Option<SecretKey>) -> Self {
let signer = UniversalSigner::new(Keys::generate());
let authenticator = SignerAuthenticator::new(signer.clone());
@@ -133,6 +136,10 @@ impl NostrRegistry {
if cfg!(target_arch = "wasm32") {
cx.emit(StateEvent::NoSigner);
} else if let Some(secret) = cli_key {
// Use CLI-provided key -- same path as get_user_credential
let keys = Keys::new(secret);
this.set_signer(keys, cx);
} else {
this.get_user_credential(cx);
}
@@ -258,6 +265,11 @@ impl NostrRegistry {
this.set_signer(signer, cx);
cx.notify();
})?;
} else if content == "proxy" {
#[cfg(not(target_arch = "wasm32"))]
this.update(cx, |this, cx| {
this.connect_proxy(cx);
})?;
}
}
_ => {
@@ -303,6 +315,81 @@ impl NostrRegistry {
})
}
/// Start the browser proxy
#[cfg(not(target_arch = "wasm32"))]
pub fn connect_proxy(&mut self, cx: &mut Context<Self>) {
let proxy = BrowserSignerProxy::new(BrowserSignerProxyOptions::default());
let (tx, rx) = flume::bounded::<String>(1);
self.tasks.push(Tokio::spawn_result(cx, {
let proxy = proxy.clone();
async move {
// Start the proxy and get the web url
proxy.start().await?;
// Notify GPUI
let url = proxy.url();
tx.send(url).ok();
Ok(())
}
}));
self.tasks.push(Tokio::spawn_result(cx, {
let proxy = proxy.clone();
async move {
loop {
if proxy.is_session_active() {
break;
}
smol::Timer::after(Duration::from_secs(1)).await;
}
Ok(())
}
}));
self.tasks.push(cx.spawn({
let proxy = proxy.clone();
async move |this, cx| {
while let Ok(url) = rx.recv_async().await {
this.update(cx, |this, cx| {
let save = cx.write_credentials(USER_KEYRING, "proxy", b"proxy");
cx.background_spawn(async move { save.await.ok() }).detach();
cx.open_url(&url);
this.set_signer(proxy.clone(), cx);
})?;
}
Ok(())
}
}));
// Monitor the session, if the browser disconnects, notify user to reconnect
self.tasks.push(cx.spawn({
let proxy = proxy.clone();
let executor = cx.background_executor().clone();
async move |this, cx| {
// Wait for the signer to be confirmed (timeout is 30s)
executor.timer(Duration::from_secs(30)).await;
loop {
executor.timer(Duration::from_secs(5)).await;
if !proxy.is_session_active() {
_ = this.update(cx, |this, cx| {
// Only notify if this proxy is still the active signer
if this.current_user.is_some() {
this.signer.swap_inner(Keys::generate());
this.current_user = None;
cx.emit(StateEvent::NoSigner);
cx.notify();
}
});
break;
}
}
Ok(())
}
}));
}
/// Get the public key of a NIP-05 address
pub fn query_address(&self, addr: Nip05Address, cx: &App) -> Task<Result<PublicKey, Error>> {
let client = self.client();
+4 -6
View File
@@ -1,8 +1,8 @@
use gpui::prelude::FluentBuilder;
use gpui::{
AbsoluteLength, App, Div, Hsla, ImageSource, Img, InteractiveElement, Interactivity,
IntoElement, ParentElement, RenderOnce, StyleRefinement, Styled, StyledImage, Window, div, img,
px,
IntoElement, ObjectFit, ParentElement, RenderOnce, StyleRefinement, Styled, StyledImage,
Window, div, img, px,
};
use theme::ActiveTheme;
@@ -26,9 +26,7 @@ pub(super) fn avatar_size(size: Size) -> AbsoluteLength {
/// ```
/// use ui::{Avatar};
///
/// Avatar::new("path/to/image.png")
/// .grayscale(true)
/// .border_color(gpui::red());
/// Avatar::new("path/to/image.png").grayscale(true).border_color(gpui::red());
/// ```
#[derive(IntoElement)]
pub struct Avatar {
@@ -130,7 +128,7 @@ impl RenderOnce for Avatar {
self.image
.size(image_size)
.rounded_full()
.object_fit(gpui::ObjectFit::Fill)
.object_fit(ObjectFit::Cover)
.bg(cx.theme().ghost_element_background)
.with_fallback(move || {
img("brand/avatar.png")
+16 -3
View File
@@ -51,7 +51,7 @@ impl Render for DragPanel {
.overflow_hidden()
.whitespace_nowrap()
.rounded(cx.theme().radius)
.text_xs()
.text_sm()
.text_color(cx.theme().text)
.text_ellipsis()
.when(cx.theme().shadow, |this| this.shadow_xs())
@@ -312,6 +312,7 @@ impl TabPanel {
cx.emit(PanelEvent::ZoomOut);
cx.emit(PanelEvent::LayoutChanged);
cx.notify();
}
fn detach_panel(
@@ -321,10 +322,22 @@ impl TabPanel {
cx: &mut Context<Self>,
) {
let panel_view = panel.view();
let removed_ix = self.panels.iter().position(|p| p.view() == panel_view);
self.panels.retain(|p| p.view() != panel_view);
if self.active_ix >= self.panels.len() {
self.set_active_ix(self.panels.len().saturating_sub(1), window, cx)
} else if let Some(removed_ix) = removed_ix {
if removed_ix < self.active_ix {
self.active_ix = self.active_ix.saturating_sub(1);
} else if removed_ix == self.active_ix {
// The active panel was removed and another panel shifted into
// its position. Activate the new panel at the same index.
if let Some(new_active) = self.panels.get(self.active_ix) {
new_active.set_active(true, cx);
}
self.focus_active_panel(window, cx);
}
}
}
@@ -613,7 +626,7 @@ impl TabPanel {
div()
.w_full()
.text_ellipsis()
.text_xs()
.text_sm()
.child(panel.title(cx)),
)
.when(state.draggable, |this| {
@@ -687,8 +700,8 @@ impl TabPanel {
.on_click(cx.listener({
let panel = panel.clone();
move |view, _ev, window, cx| {
cx.stop_propagation();
view.remove_panel(&panel, window, cx);
view.set_active_ix(ix, window, cx);
}
})),
)
+5 -5
View File
@@ -1,5 +1,4 @@
use std::rc::Rc;
use instant::Duration;
use gpui::prelude::FluentBuilder;
use gpui::{
@@ -7,6 +6,7 @@ use gpui::{
InteractiveElement, IntoElement, KeyBinding, MouseButton, ParentElement, Pixels, Point,
RenderOnce, SharedString, StyleRefinement, Styled, Window, anchored, div, hsla, point, px,
};
use instant::Duration;
use theme::ActiveTheme;
use crate::actions::{Cancel, Confirm};
@@ -359,8 +359,8 @@ impl RenderOnce for Modal {
let y = self.margin_top.unwrap_or(view_size.height / 10.) + offset_top;
let x = bounds.center().x - self.width / 2.;
let mut padding_right = px(8.);
let mut padding_left = px(8.);
let mut padding_right = px(16.);
let mut padding_left = px(16.);
if let Some(pl) = self.style.padding.left {
padding_left = pl.to_pixels(self.width.into(), window.rem_size());
@@ -452,8 +452,8 @@ impl RenderOnce for Modal {
.when_some(self.max_width, |this, w| this.max_w(w))
.child(
div()
.px_2()
.h_4()
.px_4()
.h_8()
.w_full()
.flex()
.items_center()
+1 -1
View File
@@ -14,7 +14,7 @@ pub fn v_flex() -> Div {
/// Returns a `Div` as divider.
pub fn divider(cx: &App) -> Div {
div().my_2().w_full().h_px().bg(cx.theme().border_variant)
div().my_1().w_full().h_px().bg(cx.theme().border_variant)
}
macro_rules! font_weight {
+17 -3
View File
@@ -183,12 +183,10 @@ impl RenderOnce for Tab {
.items_center()
.flex_shrink_0()
.h(TABBAR_HEIGHT)
.relative()
.overflow_hidden()
.text_color(fg)
.text_sm()
.when(!self.selected && !self.disabled, |this| {
this.hover(|this| this.text_color(cx.theme().secondary_foreground))
})
.when_some(self.prefix, |this, prefix| this.child(prefix))
.child(
h_flex()
@@ -222,5 +220,21 @@ impl RenderOnce for Tab {
this.on_click(move |event, window, cx| on_click(event, window, cx))
})
})
.child(
div()
.absolute()
.bottom_0()
.left_0()
.right_0()
.h_0p5()
.when(self.selected && !self.disabled, |this| {
this.bg(cx.theme().element_active)
})
.when(!self.selected && !self.disabled, |this| {
this.invisible().group_hover("", |this| {
this.visible().bg(cx.theme().secondary_background)
})
}),
)
}
}
+2
View File
@@ -14,11 +14,13 @@ chat = { path = "../chat" }
chat_ui = { path = "../chat_ui" }
settings = { path = "../settings" }
person = { path = "../person" }
auto_update = { path = "../auto_update" }
gpui.workspace = true
nostr-sdk.workspace = true
instant.workspace = true
nostr-connect.workspace = true
browser-signer-proxy = { path = "../browser-signer-proxy" }
anyhow.workspace = true
serde.workspace = true
+35 -4
View File
@@ -10,7 +10,7 @@ use state::{CoopAuthUrlHandler, NostrRegistry, USER_KEYRING};
use theme::ActiveTheme;
use ui::button::{Button, ButtonVariants};
use ui::input::{Input, InputEvent, InputState};
use ui::{Disableable, StyledExt, WindowExtension, v_flex};
use ui::{Disableable, StyledExt, WindowExtension, divider, v_flex};
#[derive(Debug)]
pub struct ImportIdentity {
@@ -164,6 +164,14 @@ impl ImportIdentity {
}));
}
#[cfg(not(target_arch = "wasm32"))]
fn proxy(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
nostr.update(cx, |this, cx| {
this.connect_proxy(cx);
});
}
fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
self.loading = status;
cx.notify();
@@ -199,10 +207,13 @@ impl ImportIdentity {
impl Render for ImportIdentity {
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
const MSG: &str = "Coop won't store your identity key on the local device. You need to re-login again in the next session. You can use Nostr Connect for persistent login.";
const BUNKER_WARN: &str = "Nostr Connect will usually take more time to get all your messages. Please keep your session open until you see all your messages.";
const KEY_WARN: &str = "Coop won't store your identity key on the local device. You need to re-login again in the next session. You can use Nostr Connect for persistent login.";
let is_wasm = cfg!(target_arch = "wasm32");
let require_password = self.key_input.read(cx).value().starts_with("ncryptsec1");
let key_warning = self.key_input.read(cx).value().starts_with("nsec1") || require_password;
let bunker_warning = self.key_input.read(cx).value().starts_with("bunker://");
v_flex()
.size_full()
@@ -227,13 +238,20 @@ impl Render for ImportIdentity {
.child(Input::new(&self.pass_input)),
)
})
.when(bunker_warning, |this| {
this.child(
div()
.text_xs()
.text_color(cx.theme().text_warning)
.child(div().child(BUNKER_WARN)),
)
})
.when(key_warning, |this| {
this.child(
div()
.text_xs()
.text_color(cx.theme().text_warning)
.child(div().font_semibold().child("Warning"))
.child(div().child(MSG)),
.child(div().child(KEY_WARN)),
)
}),
)
@@ -248,6 +266,19 @@ impl Render for ImportIdentity {
this.login(window, cx);
})),
)
.child(divider(cx))
.when(!is_wasm, |this| {
this.child(
Button::new("proxy")
.label("Connect via Web Extension (Experimental)")
.ghost_alt()
.loading(self.loading)
.disabled(self.loading)
.on_click(cx.listener(move |this, _ev, _window, cx| {
this.proxy(cx);
})),
)
})
.when_some(self.error.read(cx).as_ref(), |this, error| {
this.child(
div()
+46 -43
View File
@@ -16,7 +16,7 @@ use theme::ActiveTheme;
use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants};
use ui::indicator::Indicator;
use ui::{Icon, IconName, Sizable, StyledExt, WindowExtension, h_flex, v_flex};
use ui::{Disableable, Icon, IconName, Sizable, StyledExt, WindowExtension, h_flex, v_flex};
pub fn init(public_key: PublicKey, window: &mut Window, cx: &mut App) -> Entity<Screening> {
cx.new(|cx| Screening::new(public_key, window, cx))
@@ -84,8 +84,20 @@ impl Screening {
let task: Task<Result<bool, Error>> = cx.background_spawn(async move {
// Check if user is in contact list
let contacts = client.database().contacts_public_keys(current_user).await;
let followed = contacts.unwrap_or_default().contains(&public_key);
let filter = Filter::new()
.author(current_user)
.kind(Kind::ContactList)
.limit(1);
let followed = client
.database()
.query(filter)
.await
.unwrap_or_default()
.into_iter()
.next()
.map(|event| event.tags.public_keys().any(|k| k == public_key))
.unwrap_or(false);
Ok(followed)
});
@@ -228,11 +240,10 @@ impl Screening {
let public_key = self.public_key;
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
let tag = Nip56Tag::PublicKey {
let tag = Tag::from(Nip56Tag::PublicKey {
public_key,
report: Report::Impersonation,
}
.to_tag();
});
let event = EventBuilder::new(Kind::Reporting, "")
.tag(tag)
@@ -263,7 +274,7 @@ impl Screening {
let contacts = contacts.clone();
let total = contacts.len();
this.title(SharedString::from("Mutual contacts")).child(
this.title("Mutual contacts").child(
v_flex().gap_1().pb_2().child(
uniform_list("contacts", total, move |range, _window, cx| {
let persons = PersonRegistry::global(cx);
@@ -342,7 +353,7 @@ impl Render for Screening {
.h_7()
.justify_center()
.rounded_full()
.bg(cx.theme().surface_background)
.bg(cx.theme().elevated_surface_background)
.text_sm()
.truncate()
.text_ellipsis()
@@ -355,7 +366,8 @@ impl Render for Screening {
.gap_1()
.child(
Button::new("njump")
.label("View on njump.me")
.icon(IconName::Link)
.label("njump.me")
.secondary()
.small()
.rounded()
@@ -386,21 +398,18 @@ impl Render for Screening {
.text_sm()
.child(status_badge(Some(self.followed), cx))
.child(
v_flex()
.text_sm()
.child(SharedString::from("Contact"))
.child(
div()
.line_clamp(1)
.text_color(cx.theme().text_muted)
.child({
if self.followed {
SharedString::from(CONTACT)
} else {
SharedString::from(NOT_CONTACT)
}
}),
),
v_flex().text_sm().child("Contact").child(
div()
.line_clamp(1)
.text_color(cx.theme().text_muted)
.child({
if self.followed {
SharedString::from(CONTACT)
} else {
SharedString::from(NOT_CONTACT)
}
}),
),
),
)
.child(
@@ -415,7 +424,7 @@ impl Render for Screening {
.child(
h_flex()
.gap_0p5()
.child(SharedString::from("Activity on Public Relays"))
.child("Activity on Public Relays")
.child(
Button::new("active")
.icon(IconName::Info)
@@ -484,25 +493,8 @@ impl Render for Screening {
.gap_2()
.child(status_badge(Some(mutuals > 0), cx))
.child(
v_flex()
h_flex()
.text_sm()
.child(
h_flex()
.gap_0p5()
.child(SharedString::from("Mutual contacts"))
.child(
Button::new("mutuals")
.icon(IconName::Info)
.xsmall()
.ghost()
.rounded()
.on_click(cx.listener(
move |this, _, window, cx| {
this.mutual_contacts(window, cx);
},
)),
),
)
.child(
div()
.line_clamp(1)
@@ -514,6 +506,17 @@ impl Render for Screening {
SharedString::from(NO_MUTUAL)
}
}),
)
.child(
Button::new("mutuals")
.icon(IconName::Info)
.xsmall()
.ghost()
.rounded()
.disabled(mutuals == 0)
.on_click(cx.listener(move |this, _, window, cx| {
this.mutual_contacts(window, cx);
})),
),
),
),
+34 -4
View File
@@ -2,6 +2,7 @@ use std::sync::Arc;
use ::settings::AppSettings;
use anyhow::Error;
use auto_update::AutoUpdater;
use chat::{ChatEvent, ChatRegistry};
use common::{CoopImageCache, download_dir};
use device::{DeviceEvent, DeviceRegistry};
@@ -44,13 +45,12 @@ struct MsgRelayNotification;
#[action(namespace = workspace, no_json)]
enum Command {
ToggleTheme,
Update,
RefreshMessagingRelays,
BackupEncryption,
ImportEncryption,
RefreshEncryption,
ResetEncryption,
ShowRelayList,
ShowMessaging,
ShowProfile,
@@ -379,6 +379,17 @@ impl Workspace {
Command::ImportEncryption => {
self.import_encryption(window, cx);
}
Command::Update => {
// No-op on managed distribution channels (Flatpak/Snap) where
// the in-app updater is never initialized.
if let Some(auto_updater) = AutoUpdater::try_global(cx) {
auto_updater.update(cx, |this, cx| {
this.updater.update(cx, |updater, cx| {
updater.check(cx);
});
});
}
}
}
}
@@ -551,7 +562,7 @@ impl Workspace {
.caret()
.compact()
.transparent()
.dropdown_menu(move |this, _window, _cx| {
.dropdown_menu(move |this, _window, cx| {
let avatar = avatar.clone();
let name = name.clone();
@@ -585,7 +596,15 @@ impl Workspace {
IconName::Sun,
Box::new(Command::ToggleTheme),
)
.separator()
// Only offer in-app updates when auto-update is
// enabled (managed channels update themselves).
.when(AutoUpdater::is_available(cx), |this| {
this.separator().menu_with_icon(
"Check for Updates",
IconName::Device,
Box::new(Command::Update),
)
})
.menu_with_icon(
"Settings",
IconName::Settings,
@@ -609,9 +628,20 @@ impl Workspace {
let profile = persons.read(cx).get(&public_key, cx);
let announcement = profile.announcement();
// Update status is only shown when auto-update is available. On
// managed distribution channels (Flatpak/Snap) no updater exists, so
// nothing is rendered.
let updater_status = AutoUpdater::try_global(cx).and_then(|updater| {
let updater = updater.read(cx);
(!updater.idle(cx)).then(|| updater.status(cx))
});
h_flex()
.when(!cx.theme().platform.is_mac(), |this| this.pr_2())
.gap_2()
.when_some(updater_status, |this, status| {
this.child(div().text_xs().italic().child(status))
})
.when(nip4e_enabled, |this| {
this.child(
Button::new("key")
+14 -1
View File
@@ -88,7 +88,20 @@ impl ContactListPanel {
};
let task: Task<Result<HashSet<PublicKey>, Error>> = cx.background_spawn(async move {
let contact_list = client.database().contacts_public_keys(public_key).await?;
let filter = Filter::new()
.author(public_key)
.kind(Kind::ContactList)
.limit(1);
let contact_list: HashSet<PublicKey> = client
.database()
.query(filter)
.await?
.into_iter()
.next()
.map(|event| event.tags.public_keys().collect())
.unwrap_or_default();
Ok(contact_list)
});
@@ -93,7 +93,7 @@ impl MessagingRelayPanel {
.author(public_key)
.limit(1);
if let Some(event) = client.database().query(filter).await?.first_owned() {
if let Some(event) = client.database().query(filter).await?.into_iter().next() {
Ok(nip17::extract_relay_list(&event).collect())
} else {
Err(anyhow!("Not found."))
@@ -177,7 +177,7 @@ impl MessagingRelayPanel {
let tags: Vec<Tag> = self
.relays
.iter()
.map(|relay| Nip17Tag::Relay(relay.to_owned()).to_tag())
.map(|relay| Tag::from(Nip17Tag::Relay(relay.to_owned())))
.collect();
// Set updating state
+1 -1
View File
@@ -111,7 +111,7 @@ impl RelayListPanel {
.author(public_key)
.limit(1);
if let Some(event) = client.database().query(filter).await?.first_owned() {
if let Some(event) = client.database().query(filter).await?.into_iter().next() {
Ok(nip65::extract_relay_list(&event).collect())
} else {
Err(anyhow!("Not found."))
+1 -3
View File
@@ -164,9 +164,7 @@ impl RenderOnce for RoomEntry {
)
.on_cancel(move |_event, window, cx| {
window.dispatch_action(Box::new(ClosePanel), cx);
// Prevent closing the modal on click
// modal will be automatically closed after closing panel
false
true
})
});
}
+22 -1
View File
@@ -158,7 +158,20 @@ impl Sidebar {
};
let task: Task<Result<HashSet<PublicKey>, Error>> = cx.background_spawn(async move {
let contacts = client.database().contacts_public_keys(public_key).await?;
let filter = Filter::new()
.author(public_key)
.kind(Kind::ContactList)
.limit(1);
let contacts: HashSet<PublicKey> = client
.database()
.query(filter)
.await?
.into_iter()
.next()
.map(|event| event.tags.public_keys().collect())
.unwrap_or_default();
Ok(contacts)
});
@@ -354,6 +367,14 @@ impl Sidebar {
cx.notify();
});
self.new_requests = false;
// Reset search state when switching to inbox/requests
self.reset(window, cx);
// Clear the find input value
self.find_input.update(cx, |this, cx| {
this.set_value("", window, cx);
});
}
fn render_list_items(
+2 -1
View File
@@ -14,7 +14,7 @@ product-name = "Coop"
description = "Chat Freely, Stay Private on Nostr"
identifier = "su.reya.coop"
category = "SocialNetworking"
version = "1.0.0-beta5"
version = "1.0.1"
out-dir = "../dist"
before-packaging-command = "cargo build --release"
resources = ["Cargo.toml", "src"]
@@ -48,3 +48,4 @@ reqwest_client.workspace = true
log.workspace = true
tracing-subscriber.workspace = true
nostr-sdk.workspace = true
+4
View File
@@ -1,3 +1,7 @@
# Snaps built by snapcraft without Snap Store credentials are unsigned and
# cannot be installed without bypassing signature checks. Use:
# sudo snap install --dangerous ./coop_<version>_<arch>.snap
# For signed installs (`snap install coop`), publish via the Snap Store.
name: coop
title: Coop
base: core24
+32 -1
View File
@@ -7,6 +7,7 @@ use gpui::{
actions, point, px, size,
};
use gpui_platform::application;
use nostr_sdk::prelude::SecretKey;
use state::{APP_ID, CLIENT_NAME};
use ui::Root;
@@ -16,6 +17,14 @@ fn main() {
// Initialize logging
tracing_subscriber::fmt::init();
// Parse CLI arguments for --sec <nsec1>
let cli_key = parse_cli_key();
if let Err(ref e) = cli_key {
eprintln!("Failed to parse --sec argument: {e}");
std::process::exit(1);
}
let cli_key = cli_key.unwrap();
// Run application
application()
.with_assets(Assets)
@@ -24,6 +33,9 @@ fn main() {
// Load embedded fonts in assets/fonts
load_embedded_fonts(cx);
// Set app identity
cx.set_app_identity(APP_ID, CLIENT_NAME);
// Register the `quit` function
cx.on_action(quit);
@@ -72,7 +84,7 @@ fn main() {
settings::init(window, cx);
// Initialize the nostr client
state::init(window, cx);
state::init(window, cx, cli_key);
// Initialize person registry
person::init(window, cx);
@@ -122,6 +134,25 @@ fn load_embedded_fonts(cx: &App) {
.unwrap();
}
fn parse_cli_key() -> Result<Option<SecretKey>, String> {
let args: Vec<String> = std::env::args().collect();
let mut i = 0;
while i < args.len() {
if args[i] == "--sec" {
if i + 1 < args.len() {
let nsec = &args[i + 1];
return SecretKey::parse(nsec)
.map(Some)
.map_err(|e| format!("Invalid nsec key '{nsec}': {e}"));
} else {
return Err("--sec requires a value (nsec1...)".to_string());
}
}
i += 1;
}
Ok(None)
}
fn quit(_ev: &Quit, cx: &mut App) {
log::info!("Gracefully quitting the application . . .");
cx.quit();
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 769 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 374 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 535 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 386 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 432 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 446 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 809 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 634 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 803 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 651 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 663 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 696 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 506 KiB

+4 -4
View File
@@ -55,8 +55,8 @@ flatpak run --command=flatpak-builder-lint org.flatpak.Builder repo repo
Ensure you have:
- [ ] Committed all changes
- [ ] Tagged the release: `git tag -a v1.0.0-beta2 -m "Release v1.0.0-beta2"`
- [ ] Pushed the tag: `git push origin v1.0.0-beta2`
- [ ] Tagged the release: `git tag -a v1.0.0 -m "Release v1.0.0"`
- [ ] Pushed the tag: `git push origin v1.0.0`
- [ ] Run `./script/prepare-flathub.sh` to regenerate files
### 2. Fork and Submit
@@ -101,8 +101,8 @@ git push origin su.reya.coop
To release a new version:
1. Update version in workspace `Cargo.toml`
2. Tag the new release: `git tag -a v1.0.0-beta3 -m "Release v1.0.0-beta3"`
3. Push the tag: `git push origin v1.0.0-beta3`
2. Tag the new release: `git tag -a v1.0.0 -m "Release v1.0.0"`
3. Push the tag: `git push origin v1.0.0`
4. Run `./script/prepare-flathub.sh` to regenerate
5. Clone the flathub repo: `git clone https://github.com/flathub/su.reya.coop.git`
6. Update the manifest with new commit/tag and hashes
+4
View File
@@ -35,3 +35,7 @@ SNAP_NAME="coop_${1}_${ARCH_SUFFIX}.snap"
snapcraft --destructive-mode --output "$SNAP_NAME"
echo "Created snap package: $SNAP_NAME"
echo ""
echo "This snap is unsigned (built without Snap Store credentials)."
echo "To install it locally, use:"
echo " sudo snap install --dangerous ./$SNAP_NAME"
+16 -10
View File
@@ -29,23 +29,29 @@ fi
# Function to update version in a Cargo.toml file
update_version() {
local file="$1"
local backup="${file}.bak"
local tmp="${file}.tmp"
# Backup the original file
cp "$file" "$backup"
# More flexible regex that handles various version formats and whitespace
if sed -i -E "s/^[[:space:]]*version[[:space:]]*=[[:space:]]*\"[^\"]+\"/version = \"$NEW_VERSION\"/" "$file"; then
# Portable in-place edit. `sed -i` behaves differently on GNU sed (Linux)
# and BSD sed (macOS): on macOS `sed -i -E` treats `-E` as the backup
# suffix instead of the extended-regex flag, leaving a stray
# `Cargo.toml-E` behind and never updating the file.
# Writing to a temp file and moving it over works on both implementations.
if sed -E "s/^[[:space:]]*version[[:space:]]*=[[:space:]]*\"[^\"]+\"/version = \"$NEW_VERSION\"/" "$file" > "$tmp" \
&& mv "$tmp" "$file"; then
echo "✓ Updated version to $NEW_VERSION in $file"
else
echo "Error: Failed to update version in $file"
# Restore original backup
mv "$backup" "$file"
# Remove any partial temp file; the original file is untouched
rm -f "$tmp"
exit 1
fi
# Remove the backup file
rm -f "$backup"
# The substitution can silently match nothing (e.g. a `version.workspace` key),
# so verify the new version actually landed before moving on.
if ! grep -q "^[[:space:]]*version[[:space:]]*=[[:space:]]*\"$NEW_VERSION\"" "$file"; then
echo "Error: Version line not found/updated in $file"
exit 1
fi
}
# Update both Cargo.toml files
+16 -1
View File
@@ -15,11 +15,26 @@ if [ "$#" -ne 1 ]; then
exit 1
fi
# Get system architecture (same mapping as script/bundle-snap)
ARCH=$(uname -m)
case "$ARCH" in
x86_64) ARCH_SUFFIX="x86_64" ;;
aarch64) ARCH_SUFFIX="aarch64" ;;
*) echo "Unsupported architecture: $ARCH"; exit 1 ;;
esac
snap_file="coop_${1}_${ARCH_SUFFIX}.snap"
if [ ! -f "$snap_file" ]; then
echo "Snap file not found: $snap_file"
echo "Build it first with: script/bundle-snap $1"
exit 1
fi
# Rerun as root
[ "$UID" -eq 0 ] || exec sudo bash -e "$0" "$@"
snap remove coop || true
mkdir -p snap
rm -rf snap/unpacked
unsquashfs -dest snap/unpacked "coop_$1_amd64.snap"
unsquashfs -dest snap/unpacked "$snap_file"
snap try --classic snap/unpacked
+1 -1
View File
@@ -59,7 +59,7 @@ pub fn run() -> Result<(), JsValue> {
settings::init(window, cx);
// Initialize the nostr client
state::init(window, cx);
state::init(window, cx, None);
// Initialize person registry
person::init(window, cx);