add support for blossom
Some checks failed
Rust / build (ubuntu-latest, stable) (push) Failing after 1m42s
Rust / build (ubuntu-latest, stable) (pull_request) Failing after 1m53s
Rust / build (macos-latest, stable) (push) Has been cancelled
Rust / build (windows-latest, stable) (push) Has been cancelled
Rust / build (macos-latest, stable) (pull_request) Has been cancelled
Rust / build (windows-latest, stable) (pull_request) Has been cancelled

This commit is contained in:
2026-02-25 06:58:46 +07:00
parent 10ded51d2f
commit 6d863d8bbe
9 changed files with 237 additions and 376 deletions

View File

@@ -13,15 +13,13 @@ use gpui::{
PathPromptOptions, Render, SharedString, StatefulInteractiveElement, Styled, StyledImage,
Subscription, Task, WeakEntity, Window,
};
use gpui_tokio::Tokio;
use itertools::Itertools;
use nostr_sdk::prelude::*;
use person::{Person, PersonRegistry};
use settings::{AppSettings, SignerKind};
use smallvec::{smallvec, SmallVec};
use smol::fs;
use smol::lock::RwLock;
use state::{nostr_upload, NostrRegistry};
use state::{upload, NostrRegistry};
use theme::ActiveTheme;
use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants};
@@ -522,12 +520,10 @@ impl ChatPanel {
}
fn upload(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
// Get the user's configured NIP96 server
let nip96_server = AppSettings::get_file_server(cx);
// Get the user's configured blossom server
let server = AppSettings::get_file_server(cx);
// Ask user for file upload
let path = cx.prompt_for_paths(PathPromptOptions {
files: true,
directories: false,
@@ -536,34 +532,27 @@ impl ChatPanel {
});
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
this.update(cx, |this, cx| {
this.set_uploading(true, cx);
})?;
let mut paths = path.await??.context("Not found")?;
let path = paths.pop().context("No path")?;
let upload = Tokio::spawn(cx, async move {
let file = fs::read(path).await.ok()?;
let url = nostr_upload(&client, &nip96_server, file).await.ok()?;
Some(url)
});
if let Ok(task) = upload.await {
this.update(cx, |this, cx| {
this.set_uploading(true, cx);
})
.ok();
this.update_in(cx, |this, _window, cx| {
match task {
Some(url) => {
this.add_attachment(url, cx);
this.set_uploading(false, cx);
}
None => {
this.set_uploading(false, cx);
}
};
})
.ok();
// Upload via blossom client
match upload(server, path, cx).await {
Ok(url) => {
this.update_in(cx, |this, _window, cx| {
this.add_attachment(url, cx);
this.set_uploading(false, cx);
})?;
}
Err(e) => {
this.update_in(cx, |this, window, cx| {
this.set_uploading(false, cx);
window.push_notification(Notification::error(e.to_string()), cx);
})?;
}
}
Ok(())

View File

@@ -1,18 +1,16 @@
use std::str::FromStr;
use std::time::Duration;
use anyhow::{anyhow, Error};
use anyhow::{Context as AnyhowContext, Error};
use gpui::{
div, rems, AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter,
FocusHandle, Focusable, IntoElement, ParentElement, PathPromptOptions, Render, SharedString,
Styled, Task, Window,
};
use gpui_tokio::Tokio;
use nostr_sdk::prelude::*;
use person::{shorten_pubkey, Person, PersonRegistry};
use settings::AppSettings;
use smol::fs;
use state::{nostr_upload, NostrRegistry};
use state::{upload, NostrRegistry};
use theme::ActiveTheme;
use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants};
@@ -150,66 +148,51 @@ impl ProfilePanel {
}
}
fn uploading(&mut self, status: bool, cx: &mut Context<Self>) {
fn set_uploading(&mut self, status: bool, cx: &mut Context<Self>) {
self.uploading = status;
cx.notify();
}
fn upload(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.uploading(true, cx);
// Get the user's configured blossom server
let server = AppSettings::get_file_server(cx);
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
// Get the user's configured NIP96 server
let nip96_server = AppSettings::get_file_server(cx);
// Open native file dialog
let paths = cx.prompt_for_paths(PathPromptOptions {
// Ask user for file upload
let path = cx.prompt_for_paths(PathPromptOptions {
files: true,
directories: false,
multiple: false,
prompt: None,
});
let task = Tokio::spawn(cx, async move {
match paths.await {
Ok(Ok(Some(mut paths))) => {
if let Some(path) = paths.pop() {
let file = fs::read(path).await?;
let url = nostr_upload(&client, &nip96_server, file).await?;
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
this.update(cx, |this, cx| {
this.set_uploading(true, cx);
})?;
Ok(url)
} else {
Err(anyhow!("Path not found"))
}
}
_ => Err(anyhow!("Error")),
}
});
let mut paths = path.await??.context("Not found")?;
let path = paths.pop().context("No path")?;
cx.spawn_in(window, async move |this, cx| {
let result = task.await;
this.update_in(cx, |this, window, cx| {
match result {
Ok(Ok(url)) => {
// Upload via blossom client
match upload(server, path, cx).await {
Ok(url) => {
this.update_in(cx, |this, window, cx| {
this.avatar_input.update(cx, |this, cx| {
this.set_value(url.to_string(), window, cx);
});
}
Ok(Err(e)) => {
window.push_notification(e.to_string(), cx);
}
Err(e) => {
log::warn!("Failed to upload avatar: {e}");
}
};
this.uploading(false, cx);
})
.expect("Entity has been released");
})
.detach();
this.set_uploading(false, cx);
})?;
}
Err(e) => {
this.update_in(cx, |this, window, cx| {
this.set_uploading(false, cx);
window.push_notification(Notification::error(e.to_string()), cx);
})?;
}
}
Ok(())
}));
}
fn set_updating(&mut self, updating: bool, cx: &mut Context<Self>) {

View File

@@ -119,7 +119,7 @@ pub struct Settings {
/// Configuration for each chat room
pub room_configs: HashMap<u64, RoomConfig>,
/// File server for NIP-96 media attachments
/// Server for blossom media attachments
pub file_server: Url,
}
@@ -131,7 +131,7 @@ impl Default for Settings {
auth_mode: AuthMode::default(),
trusted_relays: HashSet::default(),
room_configs: HashMap::default(),
file_server: Url::parse("https://nostrmedia.com").unwrap(),
file_server: Url::parse("https://blossom.band/").unwrap(),
}
}
}

View File

@@ -11,6 +11,7 @@ nostr.workspace = true
nostr-sdk.workspace = true
nostr-lmdb.workspace = true
nostr-connect.workspace = true
nostr-blossom.workspace = true
gpui.workspace = true
gpui_tokio.workspace = true
@@ -25,4 +26,4 @@ serde_json.workspace = true
rustls = "0.23"
petname = "2.0.2"
whoami = "1.6.1"
reqwest = { version = "0.12", features = ["multipart", "stream", "json"] }
mime_guess = "2.0.4"

View File

@@ -0,0 +1,85 @@
use std::path::PathBuf;
use anyhow::{anyhow, Error};
use gpui::AsyncApp;
use gpui_tokio::Tokio;
use mime_guess::from_path;
use nostr_blossom::prelude::*;
use nostr_sdk::prelude::*;
pub async fn upload(server: Url, path: PathBuf, cx: &AsyncApp) -> Result<Url, Error> {
let content_type = from_path(&path).first_or_octet_stream().to_string();
let data = smol::fs::read(path).await?;
let keys = Keys::generate();
// Construct the blossom client
let client = BlossomClient::new(server);
Tokio::spawn(cx, async move {
let blob = client
.upload_blob(data, Some(content_type), None, Some(&keys))
.await?;
Ok(blob.url)
})
.await
.map_err(|e| anyhow!("Upload error: {e}"))?
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_mime_type_detection() {
// Test common file extensions
assert_eq!(
from_path("image.jpg").first_or_octet_stream().to_string(),
"image/jpeg"
);
assert_eq!(
from_path("document.pdf")
.first_or_octet_stream()
.to_string(),
"application/pdf"
);
assert_eq!(
from_path("page.html").first_or_octet_stream().to_string(),
"text/html"
);
assert_eq!(
from_path("data.json").first_or_octet_stream().to_string(),
"application/json"
);
assert_eq!(
from_path("script.js").first_or_octet_stream().to_string(),
"text/javascript"
);
assert_eq!(
from_path("style.css").first_or_octet_stream().to_string(),
"text/css"
);
// Test unknown extension falls back to octet-stream
assert_eq!(
from_path("unknown.xyz").first_or_octet_stream().to_string(),
"chemical/x-xyz"
);
// Test no extension falls back to octet-stream
assert_eq!(
from_path("file_without_extension")
.first_or_octet_stream()
.to_string(),
"application/octet-stream"
);
// Test truly unknown extension
assert_eq!(
from_path("unknown.unknown123")
.first_or_octet_stream()
.to_string(),
"application/octet-stream"
);
}
}

View File

@@ -10,18 +10,18 @@ use nostr_connect::prelude::*;
use nostr_lmdb::prelude::*;
use nostr_sdk::prelude::*;
mod blossom;
mod constants;
mod device;
mod gossip;
mod nip05;
mod nip96;
mod signer;
pub use blossom::*;
pub use constants::*;
pub use device::*;
pub use gossip::*;
pub use nip05::*;
pub use nip96::*;
pub use signer::*;
pub fn init(window: &mut Window, cx: &mut App) {

View File

@@ -1,83 +0,0 @@
use anyhow::anyhow;
use nostr::hashes::sha256::Hash as Sha256Hash;
use nostr::hashes::Hash;
use nostr::prelude::*;
use nostr_sdk::prelude::*;
use reqwest::{multipart, Client as ReqClient, Response};
pub(crate) fn make_multipart_form(
file_data: Vec<u8>,
mime_type: Option<&str>,
) -> Result<multipart::Form, anyhow::Error> {
let form_file_part = multipart::Part::bytes(file_data).file_name("filename");
// Set the part's MIME type, or leave it as is if mime_type is None
let part = match mime_type {
Some(mime) => form_file_part.mime_str(mime)?,
None => form_file_part,
};
Ok(multipart::Form::new().part("file", part))
}
pub(crate) async fn upload<T>(
signer: &T,
desc: &ServerConfig,
file_data: Vec<u8>,
mime_type: Option<&str>,
) -> Result<Url, anyhow::Error>
where
T: NostrSigner,
{
let payload: Sha256Hash = Sha256Hash::hash(&file_data);
let data: HttpData = HttpData::new(desc.api_url.clone(), HttpMethod::POST).payload(payload);
let nip98_auth: String = data.to_authorization(signer).await?;
// Make form
let form: multipart::Form = make_multipart_form(file_data, mime_type)?;
// Make req client
let req_client = ReqClient::new();
// Send
let response: Response = req_client
.post(desc.api_url.clone())
.header("Authorization", nip98_auth)
.multipart(form)
.send()
.await?;
// Parse response
let json: Value = response.json().await?;
let upload_response = nip96::UploadResponse::from_json(json.to_string())?;
if upload_response.status == UploadResponseStatus::Error {
return Err(anyhow!(upload_response.message));
}
Ok(upload_response.download_url()?.to_owned())
}
pub async fn nostr_upload(
client: &Client,
server: &Url,
file: Vec<u8>,
) -> Result<Url, anyhow::Error> {
let req_client = ReqClient::new();
let config_url = nip96::get_server_config_url(server)?;
// Get
let res = req_client.get(config_url.to_string()).send().await?;
let json: Value = res.json().await?;
let config = nip96::ServerConfig::from_json(json.to_string())?;
let signer = client
.signer()
.cloned()
.unwrap_or(Keys::generate().into_nostr_signer());
let url = upload(&signer, &config, file, None).await?;
Ok(url)
}