From a27ecd767e80a559809e17db75f21d2921fe8b47 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sat, 24 Oct 2026 21:30:45 +0700 Subject: [PATCH] support file picking and uploads on web --- Cargo.lock | 4 + crates/chat_ui/src/file.rs | 58 ++++- crates/chat_ui/src/lib.rs | 102 ++++----- crates/nip29_ui/src/lib.rs | 18 +- crates/nip4e/src/lib.rs | 29 ++- crates/state/Cargo.toml | 13 ++ crates/state/src/blossom.rs | 39 ++-- crates/state/src/file.rs | 266 +++++++++++++++++----- crates/state/src/lib.rs | 5 +- crates/state/src/signer.rs | 6 + crates/state/src/web_file.rs | 198 ++++++++++++++++ crates/workspace/src/dialogs/new_group.rs | 17 +- crates/workspace/src/dialogs/profile.rs | 19 +- crates/workspace/src/dialogs/settings.rs | 64 ++++-- crates/workspace/src/lib.rs | 74 ++++-- crates/workspace/src/sidebar/header.rs | 11 +- 16 files changed, 677 insertions(+), 246 deletions(-) create mode 100644 crates/state/src/web_file.rs diff --git a/Cargo.lock b/Cargo.lock index b3567e7f..2cceff40 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7446,6 +7446,7 @@ dependencies = [ "gpui-pre", "gpui_tokio", "instant", + "js-sys", "mime_guess", "nostr", "nostr-blossom", @@ -7459,6 +7460,9 @@ dependencies = [ "serde_json", "sha2 0.10.9", "smol", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", "webbrowser", ] diff --git a/crates/chat_ui/src/file.rs b/crates/chat_ui/src/file.rs index 1d8cc168..c4076d96 100644 --- a/crates/chat_ui/src/file.rs +++ b/crates/chat_ui/src/file.rs @@ -1,32 +1,70 @@ -use std::path::{Path, PathBuf}; - -use chat::FileAttachment; +use anyhow::Error; use gpui::SharedString; use nostr_sdk::prelude::*; +use state::{FileAttachment, LocalFile, PickedFile}; /// A file attachment that has been uploaded, but not sent yet. /// -/// The local `path` is kept around so the composer can preview +/// The `local` handle is kept around so the composer can preview /// the file without downloading and decrypting it again. pub(crate) struct PendingFile { pub file: FileAttachment, - pub path: PathBuf, + pub local: LocalFile, } /// State of the encrypted file attachment of a message pub(crate) enum DecryptedFile { Loading, - Ready(PathBuf), + Ready(LocalFile), Failed(SharedString), } /// Result of an upload, either plain or encrypted pub(crate) enum Uploaded { Url(Url), - File(FileAttachment, PathBuf), + File(Box), } -/// A `file://` url for a decrypted file, so it can be opened by the OS -pub(crate) fn file_url(path: &Path) -> String { - format!("file://{}", path.display()) +/// An encrypted upload and the handle to preview it locally +pub(crate) struct UploadedFile { + pub file: FileAttachment, + pub local: LocalFile, +} + +impl Uploaded { + /// Build the upload result for a picked file, encrypted when the + /// attachment is the whole message + pub(crate) async fn from_pick( + encrypted: bool, + server: Url, + picked: PickedFile, + cx: &gpui::AsyncApp, + ) -> Result { + if encrypted { + let UploadedFileParts { file, local } = upload_encrypted(server, picked, cx).await?; + + Ok(Self::File(Box::new(UploadedFile { file, local }))) + } else { + state::upload_file(server, picked, cx).await.map(Self::Url) + } + } +} + +/// The pieces of an encrypted upload, split so the local handle survives the +/// picked file being consumed by the upload +struct UploadedFileParts { + file: FileAttachment, + local: LocalFile, +} + +async fn upload_encrypted( + server: Url, + picked: PickedFile, + cx: &gpui::AsyncApp, +) -> Result { + let local = picked.local.clone(); + + state::upload_encrypted_file(server, picked, cx) + .await + .map(|file| UploadedFileParts { file, local }) } diff --git a/crates/chat_ui/src/lib.rs b/crates/chat_ui/src/lib.rs index 2015d461..28e0f739 100644 --- a/crates/chat_ui/src/lib.rs +++ b/crates/chat_ui/src/lib.rs @@ -1,5 +1,4 @@ use std::collections::{BTreeMap, HashMap, HashSet}; -use std::path::PathBuf; use std::sync::{Arc, LazyLock, RwLock}; pub use actions::*; @@ -10,10 +9,9 @@ use gpui::prelude::FluentBuilder; 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, SystemNotification, - SystemNotificationAction, Task, WeakEntity, Window, div, img, list, px, red, relative, - retain_all, svg, white, + ObjectFit, ParentElement, Render, SharedString, SharedUri, StatefulInteractiveElement, Styled, + StyledImage, Subscription, SystemNotification, SystemNotificationAction, Task, WeakEntity, + Window, div, img, list, px, red, relative, retain_all, svg, white, }; use gpui_kit::base::StyledExt as _; use gpui_kit::component::button::{Button, ButtonVariants}; @@ -31,7 +29,7 @@ use regex::Regex; use settings::{AppSettings, SignerKind}; use smallvec::{SmallVec, smallvec}; use state::{ - FileAttachment, NostrRegistry, download_and_decrypt_to_file, upload, upload_encrypted, + FileAttachment, LocalFile, NostrRegistry, PickedFile, download_and_decrypt_file, pick_file, }; use ui::{ Avatar, COMPACT_REACTION_EMOJIS, IconName, MessageActions, MessageMedia, MessageRow, @@ -642,22 +640,17 @@ impl ChatPanel { // Encrypt attachments which are not part of a message being written let encrypted = self.input.read(cx).value().trim().is_empty(); - // Ask user for file upload - let path = cx.prompt_for_paths(PathPromptOptions { - files: true, - directories: false, - multiple: false, - prompt: None, - }); + // Ask the user for a file to upload + let picker = pick_file(cx); self.tasks.push(cx.spawn_in(window, async move |this, cx| { // Selecting no file means the prompt was cancelled - let Some(path) = path.await??.and_then(|mut paths| paths.pop()) else { + let Some(picked) = picker.await? else { return Ok(()); }; this.update_in(cx, |this, window, cx| { - this.upload_file(server, path, encrypted, window, cx); + this.upload_file(server, picked, encrypted, window, cx); })?; Ok(()) @@ -668,7 +661,7 @@ impl ChatPanel { fn upload_file( &mut self, server: Url, - path: PathBuf, + picked: PickedFile, encrypted: bool, window: &mut Window, cx: &mut Context, @@ -676,25 +669,18 @@ impl ChatPanel { self.set_uploading(true, cx); self.tasks.push(cx.spawn_in(window, async move |this, cx| { - let result = if encrypted { - upload_encrypted(server.clone(), path.clone(), cx) - .await - .map(|file| Uploaded::File(file, path.clone())) - } else { - upload(server.clone(), path.clone(), cx) - .await - .map(Uploaded::Url) - }; + let result = Uploaded::from_pick(encrypted, server.clone(), picked, cx).await; this.update_in(cx, |this, window, cx| { this.set_uploading(false, cx); match result { Ok(Uploaded::Url(url)) => this.add_attachment(url, cx), - Ok(Uploaded::File(file, path)) => this.add_pending_file(file, path, cx), - Err(e) if encrypted => { - this.report_encrypted_upload_error(server, path, e, window, cx) + Ok(Uploaded::File(uploaded)) => { + let UploadedFile { file, local } = *uploaded; + this.add_pending_file(file, local, cx); } + Err(e) if encrypted => this.report_encrypted_upload_error(e, window, cx), Err(e) => { window.push_notification( Notification::error(e.to_string()).autohide(false), @@ -711,8 +697,6 @@ impl ChatPanel { /// Report a failed encrypted upload, offering to retry it without encryption fn report_encrypted_upload_error( &mut self, - server: Url, - path: PathBuf, error: Error, window: &mut Window, cx: &mut Context, @@ -724,14 +708,12 @@ impl ChatPanel { .title("Encrypted upload failed") .action(move |_this, _window, _cx| { let view = view.clone(); - let server = server.clone(); - let path = path.clone(); Button::new("retry-without-encryption") - .label("Upload without encryption") + .label("Pick the file again without encryption") .on_click(move |_ev, window, cx| { view.update(cx, |this, cx| { - this.upload_file(server.clone(), path.clone(), false, window, cx); + this.upload(window, cx); }) .ok(); }) @@ -761,9 +743,9 @@ impl ChatPanel { }); } - fn add_pending_file(&mut self, file: FileAttachment, path: PathBuf, cx: &mut Context) { + fn add_pending_file(&mut self, file: FileAttachment, local: LocalFile, cx: &mut Context) { self.encrypted_attachments.update(cx, |this, cx| { - this.push(PendingFile { file, path }); + this.push(PendingFile { file, local }); cx.notify(); }); } @@ -782,12 +764,13 @@ impl ChatPanel { self.decrypted_files.insert(id, DecryptedFile::Loading); self.tasks.push(cx.spawn(async move |this, cx| { - let result = download_and_decrypt_to_file(&file, cx).await; + let result = download_and_decrypt_file(&file, cx).await; this.update(cx, |this, cx| { match result { - Ok(path) => { - this.decrypted_files.insert(id, DecryptedFile::Ready(path)); + Ok(downloaded) => { + this.decrypted_files + .insert(id, DecryptedFile::Ready(downloaded.local)); } Err(e) => { this.decrypted_files @@ -802,7 +785,7 @@ impl ChatPanel { })); } - /// Decrypt the attachment of a file message and open it with the OS + /// Decrypt the attachment of a file message and open it with the platform handler fn open_file( &mut self, id: EventId, @@ -810,25 +793,30 @@ impl ChatPanel { window: &mut Window, cx: &mut Context, ) { - match self.decrypted_files.get(&id) { - Some(DecryptedFile::Ready(path)) => { - cx.open_url(&file_url(path)); - return; - } - Some(DecryptedFile::Loading) => return, - _ => {} - }; + if let Some(DecryptedFile::Ready(local)) = self.decrypted_files.get(&id) { + let _ = local.open(cx).map_err(|e| { + window.push_notification(Notification::error(e.to_string()).autohide(false), cx); + }); + return; + } + + if let Some(DecryptedFile::Loading) = self.decrypted_files.get(&id) { + return; + } self.decrypted_files.insert(id, DecryptedFile::Loading); self.tasks.push(cx.spawn_in(window, async move |this, cx| { - let result = download_and_decrypt_to_file(&file, cx).await; + let result = download_and_decrypt_file(&file, cx).await; - this.update_in(cx, |this, _window, cx| { + this.update_in(cx, |this, window, cx| { match result { - Ok(path) => { - cx.open_url(&file_url(&path)); - this.decrypted_files.insert(id, DecryptedFile::Ready(path)); + Ok(downloaded) => { + if let Err(e) = downloaded.open(cx) { + window.push_notification(Notification::error(e.to_string()), cx); + } + this.decrypted_files + .insert(id, DecryptedFile::Ready(downloaded.local)); } Err(e) => { this.decrypted_files @@ -1475,16 +1463,16 @@ impl ChatPanel { ) -> AnyElement { let state = self.decrypted_files.get(id); - if let Some(path) = state + if let Some(local) = state .and_then(|state| match state { - DecryptedFile::Ready(path) => Some(path), + DecryptedFile::Ready(local) => Some(local), _ => None, }) .filter(|_| file.is_image()) { return div() .child( - img(path.clone()) + img(local.image_source()) .border_1() .border_color(cx.theme().border) .h(px(250.)) @@ -1560,7 +1548,7 @@ impl ChatPanel { .map(|this| { if file.is_image() { this.child( - img(pending.path.clone()) + img(pending.local.image_source()) .size_16() .when(cx.theme().shadow, |this| this.shadow_sm()) .rounded(cx.theme().radius) diff --git a/crates/nip29_ui/src/lib.rs b/crates/nip29_ui/src/lib.rs index 60c580a5..2d2b187a 100644 --- a/crates/nip29_ui/src/lib.rs +++ b/crates/nip29_ui/src/lib.rs @@ -4,9 +4,8 @@ use anyhow::Result; use gpui::prelude::FluentBuilder; use gpui::{ Action, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, - FollowMode, IntoElement, ListAlignment, ListScrollEvent, ListState, ParentElement, - PathPromptOptions, Render, SharedString, Styled, Subscription, Task, WeakEntity, Window, div, - list, px, + FollowMode, IntoElement, ListAlignment, ListScrollEvent, ListState, ParentElement, Render, + SharedString, Styled, Subscription, Task, WeakEntity, Window, div, list, px, }; use gpui_kit::assets::IconName; use gpui_kit::base::StyledExt as _; @@ -23,7 +22,7 @@ use person::Directory; use serde::Deserialize; use settings::AppSettings; use smallvec::{SmallVec, smallvec}; -use state::{NostrRegistry, upload}; +use state::{NostrRegistry, pick_file, upload_file}; use ui::{Avatar, RenderedText, WelcomeMessage, rendered_text}; use util::{attachment_name, display_name, opens_run, pin_label, report}; @@ -553,22 +552,17 @@ impl GroupPanel { fn upload(&mut self, window: &mut Window, cx: &mut Context) { let server = AppSettings::get_file_server(cx); - let path = cx.prompt_for_paths(PathPromptOptions { - files: true, - directories: false, - multiple: false, - prompt: None, - }); + let picker = pick_file(cx); self.tasks.push(cx.spawn_in(window, async move |this, cx| { // Selecting no file means the prompt was cancelled - let Some(path) = path.await??.and_then(|mut paths| paths.pop()) else { + let Some(picked) = picker.await? else { return Ok(()); }; this.update(cx, |this, cx| this.set_uploading(true, cx))?; - match upload(server, path, cx).await { + match upload_file(server, picked, cx).await { Ok(url) => { this.update_in(cx, |this, _window, cx| { this.attachments.push(url); diff --git a/crates/nip4e/src/lib.rs b/crates/nip4e/src/lib.rs index 3bcfe081..bb1dbbea 100644 --- a/crates/nip4e/src/lib.rs +++ b/crates/nip4e/src/lib.rs @@ -221,25 +221,38 @@ impl DeviceRegistry { cx.emit(DeviceEvent::Set); } - /// Backup the encryption's secret key to a file - pub fn backup(&self, path: PathBuf, cx: &App) -> Task> { + /// Backup the encryption's secret key, returning the `nsec1...` content + pub fn backup_bytes(&self, cx: &App) -> Task> { let nostr = NostrRegistry::global(cx); let client = nostr.read(cx).client(); let signer = nostr.read(cx).signer(); cx.background_spawn(async move { let keys = get_keys(&client, &signer).await?; - let content = keys.secret_key().to_bech32()?; + Ok(keys.secret_key().to_bech32()?) + }) + } - if cfg!(target_arch = "wasm32") { - return Err(anyhow!("Not supported")); - } + /// Backup the encryption's secret key to a file + pub fn backup(&self, path: PathBuf, cx: &App) -> Task> { + let backup = self.backup_bytes(cx); - #[cfg(not(target_arch = "wasm32"))] + #[cfg(not(target_arch = "wasm32"))] + return cx.background_spawn(async move { + let content = backup.await?; smol::fs::write(path, &content).await?; Ok(()) - }) + }); + + #[cfg(target_arch = "wasm32")] + { + let _ = path; + cx.background_spawn(async move { + backup.await?; + Err(anyhow!("File backups are not supported on web; copy the key from the backup panel instead.")) + }) + } } /// Get device announcement for current user diff --git a/crates/state/Cargo.toml b/crates/state/Cargo.toml index d65f4cc4..1485dffd 100644 --- a/crates/state/Cargo.toml +++ b/crates/state/Cargo.toml @@ -28,8 +28,21 @@ sha2.workspace = true data-encoding.workspace = true [target.'cfg(target_arch = "wasm32")'.dependencies] +js-sys = "0.3" nostr-browser-signer.workspace = true nostr-memory.workspace = true +wasm-bindgen = "0.2" +wasm-bindgen-futures = "0.4" +web-sys = { version = "0.3", features = [ + "Blob", + "BlobPropertyBag", + "File", + "FileList", + "HtmlAnchorElement", + "HtmlInputElement", + "Url", + "Window", +] } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] browser-signer-proxy = { path = "../browser-signer-proxy" } diff --git a/crates/state/src/blossom.rs b/crates/state/src/blossom.rs index eed8c6b6..437eb5b1 100644 --- a/crates/state/src/blossom.rs +++ b/crates/state/src/blossom.rs @@ -1,17 +1,10 @@ -use std::path::PathBuf; - use anyhow::{Error, anyhow}; use gpui::AsyncApp; #[cfg(not(target_arch = "wasm32"))] use gpui_tokio::Tokio; -#[cfg(not(target_arch = "wasm32"))] -use mime_guess::from_path; use nostr_blossom::prelude::*; use nostr_sdk::prelude::*; -#[cfg(not(target_arch = "wasm32"))] -use crate::file::sha256_hex; - /// Upload a blob to a blossom server and return its URL #[cfg(not(target_arch = "wasm32"))] pub(crate) async fn upload_blob( @@ -41,16 +34,26 @@ pub(crate) async fn upload_blob( .map_err(|e| anyhow!("Upload error: {e}"))? } -#[cfg(not(target_arch = "wasm32"))] -pub async fn upload(server: Url, path: PathBuf, cx: &AsyncApp) -> Result { - let content_type = from_path(&path).first_or_octet_stream().to_string(); - let data = smol::fs::read(&path).await?; - let sha256 = sha256_hex(&data); - - upload_blob(&server, data, &content_type, &sha256, cx).await -} - +/// Upload a blob to a blossom server and return its URL. #[cfg(target_arch = "wasm32")] -pub async fn upload(_server: Url, _path: PathBuf, _cx: &AsyncApp) -> Result { - Err(anyhow!("File upload not supported on web")) +pub(crate) async fn upload_blob( + server: &Url, + data: Vec, + content_type: &str, + sha256: &str, + _cx: &AsyncApp, +) -> Result { + let client = BlossomClient::new(server.clone()); + let keys = Keys::generate(); + let base = server.clone(); + let hash = sha256.to_string(); + + match client + .upload_blob(data, Some(content_type.to_string()), None, Some(&keys)) + .await + { + Ok(blob) => Ok(blob.url), + Err(e) if e.to_string().contains("201 Created") => Ok(base.join(&hash)?), + Err(e) => Err(anyhow!(e.to_string())), + } } diff --git a/crates/state/src/file.rs b/crates/state/src/file.rs index 2dc380a3..3f7f6e87 100644 --- a/crates/state/src/file.rs +++ b/crates/state/src/file.rs @@ -7,8 +7,10 @@ use aes_gcm::{Aes256Gcm, AesGcm, Nonce}; use anyhow::{Error, anyhow, bail}; use data_encoding::HEXLOWER; use futures::AsyncReadExt; +#[cfg(not(target_arch = "wasm32"))] +use gpui::PathPromptOptions; use gpui::http_client::AsyncBody; -use gpui::{AsyncApp, SharedString}; +use gpui::{AppContext as _, AsyncApp, SharedString, SharedUri, Task}; #[cfg(not(target_arch = "wasm32"))] use mime_guess::from_path; use nostr::nips::nip94::Sha256Hash; @@ -115,6 +117,56 @@ impl FileAttachment { } } +/// A file the user picked, read into memory. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PickedFile { + pub name: String, + pub mime: String, + pub data: Vec, + pub local: LocalFile, +} + +/// A handle to a file stored on the user's device +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LocalFile { + /// A path on disk (desktop) + Path(PathBuf), + /// An in-memory blob URL (web) + Url(String), +} + +impl LocalFile { + /// The source an `img` element can render + pub fn image_source(&self) -> gpui::ImageSource { + match self { + LocalFile::Path(path) => path.clone().into(), + LocalFile::Url(url) => SharedUri::from(url.clone()).into(), + } + } + + /// The URL the platform opener understands + pub fn open_url(&self) -> String { + match self { + LocalFile::Path(path) => format!("file://{}", path.display()), + LocalFile::Url(url) => url.clone(), + } + } + + /// Open the file with the platform handler + pub fn open(&self, cx: &gpui::App) -> Result<(), Error> { + match self { + LocalFile::Path(_) => { + cx.open_url(&self.open_url()); + Ok(()) + } + #[cfg(target_arch = "wasm32")] + LocalFile::Url(url) => crate::web_file::open_url(url), + #[cfg(not(target_arch = "wasm32"))] + LocalFile::Url(_) => Ok(()), + } + } +} + pub(crate) fn encrypt(data: &[u8]) -> Result { let key = Aes256Gcm::generate_key(OsRng); let nonce = AesGcm::::generate_nonce(OsRng); @@ -165,21 +217,63 @@ pub(crate) fn sha256_hex(data: &[u8]) -> String { Sha256Hash::from_byte_array(hash).to_hex() } -#[cfg(not(target_arch = "wasm32"))] -pub async fn upload_encrypted( +/// Ask the user for a single file and read it into memory +pub fn pick_file(cx: &gpui::App) -> Task, Error>> { + #[cfg(not(target_arch = "wasm32"))] + { + let dialog = cx.prompt_for_paths(PathPromptOptions { + files: true, + directories: false, + multiple: false, + prompt: None, + }); + + cx.background_spawn(async move { + let Some(path) = dialog.await?? else { + return Ok(None); + }; + let path = path.into_iter().next().ok_or_else(|| anyhow!("No file"))?; + + let mime = from_path(&path).first_or_octet_stream().to_string(); + let name = path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.display().to_string()); + let data = smol::fs::read(&path).await?; + + Ok(Some(PickedFile { + name, + mime, + data, + local: LocalFile::Path(path), + })) + }) + } + + #[cfg(target_arch = "wasm32")] + { + let _ = cx; + cx.background_spawn(async move { crate::web_file::pick_file().await }) + } +} + +/// Upload a file unencrypted and return its URL +pub async fn upload_file(server: Url, file: PickedFile, cx: &AsyncApp) -> Result { + let PickedFile { mime, data, .. } = file; + let sha256 = sha256_hex(&data); + + crate::blossom::upload_blob(&server, data, &mime, &sha256, cx).await +} + +/// Upload a file encrypted as an opaque blob, returning the attachment metadata +pub async fn upload_encrypted_file( server: Url, - path: PathBuf, + file: PickedFile, cx: &AsyncApp, ) -> Result { - let mime = from_path(&path).first_or_octet_stream().to_string(); - let name = path - .file_name() - .map(|name| name.to_string_lossy().into_owned()); - let data = smol::fs::read(&path).await?; - - let encrypted = encrypt(&data)?; + let encrypted = encrypt(&file.data)?; let sha256 = sha256_hex(&encrypted.data); - let original_sha256 = sha256_hex(&data); + let original_sha256 = sha256_hex(&file.data); let size = encrypted.data.len() as u64; let base_url = server.to_string(); @@ -207,26 +301,17 @@ pub async fn upload_encrypted( Ok(FileAttachment { url, - mime, + mime: file.mime, key: encrypted.key, nonce: encrypted.nonce, sha256: Some(sha256), original_sha256: Some(original_sha256), size: Some(size), dim: None, - name, + name: Some(file.name), }) } -#[cfg(target_arch = "wasm32")] -pub async fn upload_encrypted( - _server: Url, - _path: PathBuf, - _cx: &AsyncApp, -) -> Result { - Err(anyhow!("File upload not supported on web")) -} - pub(crate) async fn download_and_decrypt( url: &Url, key: &str, @@ -261,33 +346,49 @@ pub(crate) async fn download_and_decrypt( decrypt(&data, key, nonce) } -/// Download and decrypt a file attachment into a temporary file. -/// -/// The same attachment always maps to the same path, so callers can render the -/// result directly (e.g. with `img`) without downloading it more than once. -#[cfg(not(target_arch = "wasm32"))] -pub async fn download_and_decrypt_to_file( - file: &FileAttachment, - cx: &AsyncApp, -) -> Result { - let name = file - .sha256 - .clone() - .unwrap_or_else(|| sha256_hex(file.url.as_str().as_bytes())); +/// A downloaded and decrypted attachment on the user's device +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DownloadedFile { + pub local: LocalFile, + pub mime: String, + pub name: String, + /// The decrypted bytes, kept so the web can offer them as a download + #[cfg(target_arch = "wasm32")] + pub(crate) data: Vec, +} - let extension = mime_guess::get_mime_extensions_str(&file.mime) - .and_then(|extensions| extensions.first()) - .copied() - .unwrap_or("bin"); - - let path = std::env::temp_dir() - .join("coop-files") - .join(format!("{name}.{extension}")); - - if smol::fs::metadata(&path).await.is_ok() { - return Ok(path); +impl DownloadedFile { + pub fn open(&self, cx: &gpui::App) -> Result<(), Error> { + match &self.local { + LocalFile::Path(path) => { + cx.open_with_system(path); + Ok(()) + } + #[cfg(target_arch = "wasm32")] + LocalFile::Url(url) => { + if self.mime.starts_with("image/") { + crate::web_file::open_url(url) + } else { + crate::web_file::download(&self.name, &self.mime, &self.data) + } + } + #[cfg(not(target_arch = "wasm32"))] + LocalFile::Url(_) => Ok(()), + } } + /// Save the file to the user's downloads (web) + #[cfg(target_arch = "wasm32")] + pub fn save(&self) -> Result<(), Error> { + crate::web_file::download(&self.name, &self.mime, &self.data) + } +} + +/// Download and decrypt a file attachment to the user's device +pub async fn download_and_decrypt_file( + file: &FileAttachment, + cx: &AsyncApp, +) -> Result { let data = download_and_decrypt( &file.url, &file.key, @@ -297,25 +398,66 @@ pub async fn download_and_decrypt_to_file( ) .await?; - let Some(parent) = path.parent() else { - bail!("Invalid file path"); - }; - smol::fs::create_dir_all(parent).await?; + let name = file + .name + .clone() + .unwrap_or_else(|| fallback_name(&file.url)); - // Write under a temporary name first, so an interrupted download is never reused - let partial = path.with_extension("download"); - smol::fs::write(&partial, data).await?; - smol::fs::rename(&partial, &path).await?; + let local = store_file(&name, &file.mime, &data).await?; - Ok(path) + Ok(DownloadedFile { + local, + mime: file.mime.clone(), + name, + #[cfg(target_arch = "wasm32")] + data, + }) } -#[cfg(target_arch = "wasm32")] -pub async fn download_and_decrypt_to_file( - _file: &FileAttachment, - _cx: &AsyncApp, -) -> Result { - Err(anyhow!("File download not supported on web")) +/// Persist decrypted bytes somewhere the platform can render or open them +async fn store_file(name: &str, mime: &str, data: &[u8]) -> Result { + #[cfg(not(target_arch = "wasm32"))] + { + let digest = sha256_hex(name.as_bytes()); + let extension = mime_guess::get_mime_extensions_str(mime) + .and_then(|extensions| extensions.first()) + .copied() + .unwrap_or("bin"); + + let path = std::env::temp_dir() + .join("coop-files") + .join(format!("{digest}.{extension}")); + + if smol::fs::metadata(&path).await.is_err() { + let Some(parent) = path.parent() else { + bail!("Invalid file path"); + }; + smol::fs::create_dir_all(parent).await?; + + // Write under a temporary name first, so an interrupted download is never reused + let partial = path.with_extension("download"); + smol::fs::write(&partial, data).await?; + smol::fs::rename(&partial, &path).await?; + } + + Ok(LocalFile::Path(path)) + } + + #[cfg(target_arch = "wasm32")] + { + let _ = name; + let url = crate::web_file::blob_url(mime, data)?; + Ok(LocalFile::Url(url)) + } +} + +/// A file name derived from the attachment URL's last path segment +fn fallback_name(url: &Url) -> String { + url.path() + .rsplit('/') + .find(|segment| !segment.is_empty()) + .unwrap_or("attachment") + .to_string() } fn tag_value<'a>(tags: &'a Tags, name: &str) -> Option<&'a str> { diff --git a/crates/state/src/lib.rs b/crates/state/src/lib.rs index 7c285789..512110b4 100644 --- a/crates/state/src/lib.rs +++ b/crates/state/src/lib.rs @@ -4,6 +4,7 @@ use std::sync::Arc; use anyhow::{Error, anyhow}; #[cfg(not(target_arch = "wasm32"))] use browser_signer_proxy::prelude::*; +#[cfg(not(target_arch = "wasm32"))] use common::config_dir; use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task, TaskExt}; #[cfg(not(target_arch = "wasm32"))] @@ -27,7 +28,9 @@ mod nip05; mod nip4e; mod signer; -pub use blossom::*; +#[cfg(target_arch = "wasm32")] +pub mod web_file; + pub use constants::*; pub use file::*; pub use nip4e::*; diff --git a/crates/state/src/signer.rs b/crates/state/src/signer.rs index c710f0ca..724e4c50 100644 --- a/crates/state/src/signer.rs +++ b/crates/state/src/signer.rs @@ -188,7 +188,13 @@ impl AuthUrlHandler for CoopAuthUrlHandler { auth_url: Url, ) -> Pin> + Send + '_>> { Box::pin(async move { + #[cfg(not(target_arch = "wasm32"))] webbrowser::open(auth_url.as_str()).map_err(nostr_connect::error::Error::other)?; + + #[cfg(target_arch = "wasm32")] + crate::web_file::open_url(auth_url.as_str()) + .map_err(nostr_connect::error::Error::other)?; + Ok(()) }) } diff --git a/crates/state/src/web_file.rs b/crates/state/src/web_file.rs new file mode 100644 index 00000000..81f0d4d6 --- /dev/null +++ b/crates/state/src/web_file.rs @@ -0,0 +1,198 @@ +//! Browser file interoperability: a persistent hidden `` +//! picker, blob URLs for rendering, and anchor-based downloads. + +use std::cell::RefCell; + +use anyhow::{Error, anyhow}; +use flume::Sender; +use wasm_bindgen::JsCast; +use wasm_bindgen::closure::Closure; +use web_sys::{File, HtmlAnchorElement, HtmlInputElement, Url as DomUrl, window as js_window}; + +use crate::file::PickedFile; + +type PickResult = Result, Error>; + +thread_local! { + /// The picker input lives for the whole page so its change closure does + /// not have to be leaked or re-registered on every pick + static PICKER: RefCell> = const { RefCell::new(None) }; + /// The sender the active pick delivers through + static PENDING: RefCell>> = const { RefCell::new(None) }; +} + +struct Picker { + input: HtmlInputElement, + /// Keep the change handler alive for the life of the page + _on_change: Closure, +} + +/// Ask the user for a single file and read its bytes +pub async fn pick_file() -> PickResult { + let (sender, receiver) = flume::bounded(1); + + PICKER.with(|picker| -> Result<(), Error> { + let mut picker = picker.borrow_mut(); + if picker.is_none() { + *picker = Some(init_picker()?); + } + let picker = picker.as_ref().expect("picker just initialized"); + + PENDING.with(|pending| *pending.borrow_mut() = Some(sender)); + picker.input.click(); + + Ok(()) + })?; + + receiver + .recv_async() + .await + .map_err(|_| anyhow!("File picker closed"))? +} + +fn init_picker() -> Result { + let document = js_window() + .ok_or_else(|| anyhow!("no browser window"))? + .document() + .ok_or_else(|| anyhow!("no browser document"))?; + + let input = document + .create_element("input") + .map_err(|e| anyhow!("failed to create file input: {e:?}"))? + .dyn_into::() + .map_err(|_| anyhow!("created element is not an input"))?; + + input.set_type("file"); + input.style().set_property("display", "none").ok(); + + let on_change = Closure::wrap(Box::new(move || { + let Some(sender) = PENDING.with(|pending| pending.borrow_mut().take()) else { + return; + }; + + wasm_bindgen_futures::spawn_local(async move { + sender.send_async(read_selected_file().await).await.ok(); + }); + }) as Box); + + input.set_onchange(Some(on_change.as_ref().unchecked_ref())); + + let body = document.body().ok_or_else(|| anyhow!("no browser body"))?; + body.append_child(&input) + .map_err(|e| anyhow!("failed to mount file input: {e:?}"))?; + + Ok(Picker { + input, + _on_change: on_change, + }) +} + +/// Read the first file of the picker input, if one was chosen +async fn read_selected_file() -> PickResult { + let picker = PICKER + .with(|picker| picker.borrow().as_ref().map(|picker| picker.input.clone())) + .ok_or_else(|| anyhow!("file picker is not initialized"))?; + + let Some(file) = picker.files().and_then(|files| files.get(0)) else { + // The dialog was dismissed without choosing a file + return Ok(None); + }; + + let mime = non_empty(file.type_()); + let data = read_file_bytes(&file).await?; + let local = crate::file::LocalFile::Url(blob_url(&mime, &data)?); + + Ok(Some(PickedFile { + name: file.name(), + mime, + data, + local, + })) +} + +/// Read a browser `File` into memory +async fn read_file_bytes(file: &File) -> Result, Error> { + let promise = file.array_buffer(); + let buffer = wasm_bindgen_futures::JsFuture::from(promise) + .await + .map_err(|e| anyhow!("failed to read file: {e:?}"))?; + + Ok(js_sys::Uint8Array::new(&buffer).to_vec()) +} + +/// Create an object URL for bytes, so images and downloads render from memory +pub fn blob_url(mime: &str, data: &[u8]) -> Result { + let array = js_sys::Uint8Array::from(data); + let parts = js_sys::Array::new(); + parts.push(&array); + + let bag = web_sys::BlobPropertyBag::new(); + bag.set_type(mime); + + let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts.into(), &bag) + .map_err(|e| anyhow!("failed to create blob: {e:?}"))?; + + DomUrl::create_object_url_with_blob(&blob).map_err(|e| anyhow!("failed to create url: {e:?}")) +} + +/// Offer bytes to the user as a browser download +pub fn download(name: &str, mime: &str, data: &[u8]) -> Result<(), Error> { + let window = js_window().ok_or_else(|| anyhow!("no browser window"))?; + let document = window + .document() + .ok_or_else(|| anyhow!("no browser document"))?; + + let url = blob_url(mime, data)?; + + let anchor: HtmlAnchorElement = document + .create_element("a") + .map_err(|e| anyhow!("failed to create download link: {e:?}"))? + .dyn_into() + .map_err(|_| anyhow!("created element is not an anchor"))?; + anchor.set_href(&url); + anchor.set_download(name); + anchor.style().set_property("display", "none").ok(); + + let body = document.body().ok_or_else(|| anyhow!("no browser body"))?; + body.append_child(&anchor) + .map_err(|e| anyhow!("failed to mount download link: {e:?}"))?; + anchor.click(); + body.remove_child(&anchor) + .map_err(|e| anyhow!("failed to remove download link: {e:?}"))?; + + // Keep the blob alive briefly so the download is not cancelled + let callback = Closure::wrap(Box::new(move || { + DomUrl::revoke_object_url(&url).ok(); + }) as Box); + window + .set_timeout_with_callback_and_timeout_and_arguments_0( + callback.as_ref().unchecked_ref(), + 10_000, + ) + .map_err(|e| anyhow!("failed to schedule cleanup: {e:?}"))?; + // The closure only runs once, after which it can be released + callback.forget(); + + Ok(()) +} + +/// Open a URL in a new browser tab +pub fn open_url(url: &str) -> Result<(), Error> { + js_window() + .ok_or_else(|| anyhow!("no browser window"))? + .open_with_url_and_target(url, "_blank") + .map_err(|e| anyhow!("failed to open url: {e:?}"))? + .ok_or_else(|| anyhow!("the browser blocked opening the url"))?; + + Ok(()) +} + +fn non_empty(value: impl Into) -> String { + let value: String = value.into(); + + if value.is_empty() { + "application/octet-stream".to_string() + } else { + value + } +} diff --git a/crates/workspace/src/dialogs/new_group.rs b/crates/workspace/src/dialogs/new_group.rs index 06fb8a93..f41562f9 100644 --- a/crates/workspace/src/dialogs/new_group.rs +++ b/crates/workspace/src/dialogs/new_group.rs @@ -2,8 +2,8 @@ use anyhow::Error; use gpui::prelude::FluentBuilder; use gpui::{ App, AppContext, Context, Div, Entity, FocusHandle, InteractiveElement as _, IntoElement, - ParentElement, PathPromptOptions, Render, SharedString, StatefulInteractiveElement as _, - StyleRefinement, Styled, Subscription, Task, WeakEntity, Window, div, px, rems, + ParentElement, Render, SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled, + Subscription, Task, WeakEntity, Window, div, px, rems, }; use gpui_base::{Popup, Select}; use gpui_kit::assets::IconName; @@ -18,7 +18,7 @@ use gpui_kit::component::{ActiveTheme, Disableable, Icon, Sizable, WindowExt, h_ use nip29::{GroupCreateOptions, GroupKey, GroupsEvent, GroupsRegistry}; use nostr_sdk::prelude::*; use settings::AppSettings; -use state::upload; +use state::{pick_file, upload_file}; use ui::{Avatar, settings_row}; const DEFAULT_RELAYS: [&str; 2] = ["wss://relay.ditto.net", "wss://chat.wisp.talk"]; @@ -297,16 +297,11 @@ impl NewGroup { fn upload(&mut self, window: &mut Window, cx: &mut Context) { let server = AppSettings::get_file_server(cx); - let path = cx.prompt_for_paths(PathPromptOptions { - files: true, - directories: false, - multiple: false, - prompt: None, - }); + let picker = pick_file(cx); self.tasks.push(cx.spawn_in(window, async move |this, cx| { // Selecting no file means the prompt was cancelled - let Some(path) = path.await??.and_then(|mut paths| paths.pop()) else { + let Some(picked) = picker.await? else { return Ok(()); }; @@ -314,7 +309,7 @@ impl NewGroup { this.set_uploading(true, cx); })?; - match upload(server, path, cx).await { + match upload_file(server, picked, cx).await { Ok(url) => { this.update(cx, |this, cx| { this.picture = Some(url.to_string().into()); diff --git a/crates/workspace/src/dialogs/profile.rs b/crates/workspace/src/dialogs/profile.rs index 00eb388a..82a67fd8 100644 --- a/crates/workspace/src/dialogs/profile.rs +++ b/crates/workspace/src/dialogs/profile.rs @@ -4,8 +4,8 @@ use anyhow::Error; use chat::{ChatRegistry, Room, RoomKind}; use gpui::prelude::FluentBuilder; use gpui::{ - App, AppContext, ClipboardItem, Context, Div, Entity, IntoElement, ParentElement, - PathPromptOptions, Render, SharedString, Styled, Task, Window, div, retain_all, + App, AppContext, ClipboardItem, Context, Div, Entity, IntoElement, ParentElement, Render, + SharedString, Styled, Task, Window, div, retain_all, }; use gpui_kit::assets::IconName; use gpui_kit::base::StyledExt as _; @@ -18,7 +18,7 @@ use instant::Duration; use nostr_sdk::prelude::*; use person::{Directory, Person, PersonRegistry, shorten_pubkey}; use settings::AppSettings; -use state::{NostrRegistry, upload}; +use state::{NostrRegistry, pick_file, upload_file}; use ui::Avatar; use crate::Workspace; @@ -179,17 +179,12 @@ impl ProfileDialog { // 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, - multiple: false, - prompt: None, - }); + // Ask the user for an image to upload + let picker = pick_file(cx); self.tasks.push(cx.spawn_in(window, async move |this, cx| { // Selecting no file means the prompt was cancelled - let Some(path) = path.await??.and_then(|mut paths| paths.pop()) else { + let Some(picked) = picker.await? else { return Ok(()); }; @@ -198,7 +193,7 @@ impl ProfileDialog { })?; // Upload via blossom client - match upload(server, path, cx).await { + match upload_file(server, picked, cx).await { Ok(url) => { this.update_in(cx, |this, window, cx| { this.avatar_input.update(cx, |this, cx| { diff --git a/crates/workspace/src/dialogs/settings.rs b/crates/workspace/src/dialogs/settings.rs index a9f47ecf..eae40c36 100644 --- a/crates/workspace/src/dialogs/settings.rs +++ b/crates/workspace/src/dialogs/settings.rs @@ -1,7 +1,9 @@ +#[cfg(not(target_arch = "wasm32"))] +use gpui::PathPromptOptions; use gpui::http_client::Url; use gpui::{ - App, AppContext, Context, Entity, IntoElement, ParentElement, PathPromptOptions, Render, - Styled, Subscription, Window, div, + App, AppContext, Context, Entity, IntoElement, ParentElement, Render, Styled, Subscription, + Window, div, }; use gpui_kit::assets::IconName; use gpui_kit::component::button::{Button, ButtonVariants}; @@ -150,29 +152,41 @@ impl Preferences { } /// Ask the user for an image to show behind chat and group panels - fn choose_background(cx: &mut App) { - let task = cx.prompt_for_paths(PathPromptOptions { - files: true, - directories: false, - multiple: false, - prompt: None, - }); + fn choose_background( + #[cfg_attr(not(target_arch = "wasm32"), allow(unused_variables))] window: &mut Window, + cx: &mut App, + ) { + #[cfg(target_arch = "wasm32")] + { + let note = Notification::warning("Background images are not supported on web yet."); + window.push_notification(note, cx); + } - cx.spawn(async move |cx| { - let Ok(Ok(Some(mut paths))) = task.await else { - return; - }; - - let Some(path) = paths.pop() else { - return; - }; - - cx.update(|cx| { - AppSettings::update_panel_background_path(Some(path), cx); - cx.refresh_windows(); + #[cfg(not(target_arch = "wasm32"))] + { + let task = cx.prompt_for_paths(PathPromptOptions { + files: true, + directories: false, + multiple: false, + prompt: None, }); - }) - .detach(); + + cx.spawn(async move |cx| { + let Ok(Ok(Some(mut paths))) = task.await else { + return; + }; + + let Some(path) = paths.pop() else { + return; + }; + + cx.update(|cx| { + AppSettings::update_panel_background_path(Some(path), cx); + cx.refresh_windows(); + }); + }) + .detach(); + } } } @@ -330,8 +344,8 @@ impl Render for Preferences { .ghost() .small() .disabled(!panel_background) - .on_click(|_, _window, cx| { - Self::choose_background(cx); + .on_click(|_, window, cx| { + Self::choose_background(window, cx); }), ) .child( diff --git a/crates/workspace/src/lib.rs b/crates/workspace/src/lib.rs index 9ae51eff..6f99f2b0 100644 --- a/crates/workspace/src/lib.rs +++ b/crates/workspace/src/lib.rs @@ -5,6 +5,7 @@ use anyhow::Error; #[cfg(not(target_arch = "wasm32"))] use auto_update::AutoUpdater; use chat::{ChatEvent, ChatRegistry}; +#[cfg(not(target_arch = "wasm32"))] use common::download_dir; use gpui::{ Action, App, AppContext, Context, Entity, InteractiveElement, IntoElement, ParentElement, @@ -338,34 +339,57 @@ impl Workspace { } Command::BackupEncryption => { let device = DeviceRegistry::global(cx).downgrade(); - let save_dialog = cx.prompt_for_new_path(download_dir(), Some("encryption.txt")); + #[cfg(not(target_arch = "wasm32"))] + { + let save_dialog = + cx.prompt_for_new_path(download_dir(), Some("encryption.txt")); + + self.tasks.push(cx.spawn_in(window, async move |_this, cx| { + // Get the output path from the save dialog + let output_path = match save_dialog.await { + Ok(Ok(Some(path))) => path, + Ok(Ok(None)) | Err(_) => return Ok(()), + Ok(Err(error)) => { + cx.update(|window, cx| { + let message = + format!("Failed to pick save location: {error:#}"); + let note = Notification::error(message).autohide(false); + window.push_notification(note, cx); + })?; + return Ok(()); + } + }; + + // Get the backup task + let backup = device + .read_with(cx, |this, cx| this.backup(output_path.clone(), cx))?; + + // Run the backup task + backup.await?; + + // Open the backup file with the system's default application + cx.update(|_window, cx| { + cx.open_with_system(output_path.as_path()); + })?; + + Ok(()) + })); + } + + #[cfg(target_arch = "wasm32")] self.tasks.push(cx.spawn_in(window, async move |_this, cx| { - // Get the output path from the save dialog - let output_path = match save_dialog.await { - Ok(Ok(Some(path))) => path, - Ok(Ok(None)) | Err(_) => return Ok(()), - Ok(Err(error)) => { - cx.update(|window, cx| { - let message = format!("Failed to pick save location: {error:#}"); - let note = Notification::error(message).autohide(false); - window.push_notification(note, cx); - })?; - return Ok(()); - } - }; + let backup = device.read_with(cx, |this, cx| this.backup_bytes(cx))?; + let content = backup.await?; - // Get the backup task - let backup = - device.read_with(cx, |this, cx| this.backup(output_path.clone(), cx))?; - - // Run the backup task - backup.await?; - - // Open the backup file with the system's default application - cx.update(|_window, cx| { - cx.open_with_system(output_path.as_path()); - })?; + cx.update(|_window, _cx| { + state::web_file::download( + "encryption.txt", + "text/plain", + content.as_bytes(), + ) + }) + .map_err(|e| anyhow::anyhow!(e))??; Ok(()) })); diff --git a/crates/workspace/src/sidebar/header.rs b/crates/workspace/src/sidebar/header.rs index 3f19b598..70152a0f 100644 --- a/crates/workspace/src/sidebar/header.rs +++ b/crates/workspace/src/sidebar/header.rs @@ -1,16 +1,17 @@ #[cfg(not(target_arch = "wasm32"))] use auto_update::AutoUpdater; use gpui::prelude::FluentBuilder; +#[cfg(not(target_arch = "wasm32"))] +use gpui::{AnyElement, Entity, IntoElement}; use gpui::{ - AnyElement, App, Context, Div, Entity, InteractiveElement, IntoElement, ParentElement, - Stateful, Styled, Window, div, px, + App, Context, Div, InteractiveElement, ParentElement, Stateful, Styled, Window, div, px, }; +#[cfg(not(target_arch = "wasm32"))] +use gpui_kit::component::Disableable; use gpui_kit::component::button::{Button, ButtonVariant, ButtonVariants}; use gpui_kit::component::dialog::DialogButtonProps; use gpui_kit::component::menu::{DropdownMenu, PopupMenuItem}; -use gpui_kit::component::{ - ActiveTheme, Disableable, Icon, Selectable, Sizable, WindowExt, h_flex, v_flex, -}; +use gpui_kit::component::{ActiveTheme, Icon, Selectable, Sizable, WindowExt, h_flex, v_flex}; use nostr_sdk::prelude::*; use state::NostrRegistry; use ui::{Avatar, IconName};