diff --git a/assets/icons/lock.svg b/assets/icons/lock.svg new file mode 100644 index 00000000..ea03c36d --- /dev/null +++ b/assets/icons/lock.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/chat_ui/src/file.rs b/crates/chat_ui/src/file.rs new file mode 100644 index 00000000..1d8cc168 --- /dev/null +++ b/crates/chat_ui/src/file.rs @@ -0,0 +1,32 @@ +use std::path::{Path, PathBuf}; + +use chat::FileAttachment; +use gpui::SharedString; +use nostr_sdk::prelude::*; + +/// A file attachment that has been uploaded, but not sent yet. +/// +/// The local `path` 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, +} + +/// State of the encrypted file attachment of a message +pub(crate) enum DecryptedFile { + Loading, + Ready(PathBuf), + Failed(SharedString), +} + +/// Result of an upload, either plain or encrypted +pub(crate) enum Uploaded { + Url(Url), + File(FileAttachment, PathBuf), +} + +/// 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()) +} diff --git a/crates/chat_ui/src/lib.rs b/crates/chat_ui/src/lib.rs index 16c81352..3622e41b 100644 --- a/crates/chat_ui/src/lib.rs +++ b/crates/chat_ui/src/lib.rs @@ -1,4 +1,5 @@ use std::collections::{BTreeMap, HashMap, HashSet}; +use std::path::PathBuf; use std::sync::{Arc, LazyLock, RwLock}; pub use actions::*; @@ -21,7 +22,9 @@ use person::{Person, PersonRegistry}; use regex::Regex; use settings::{AppSettings, SignerKind}; use smallvec::{SmallVec, smallvec}; -use state::{NostrRegistry, upload}; +use state::{ + FileAttachment, NostrRegistry, download_and_decrypt_to_file, upload, upload_encrypted, +}; use theme::ActiveTheme; use ui::avatar::Avatar; use ui::button::{Button, ButtonVariants}; @@ -35,6 +38,7 @@ use ui::{ h_flex, v_flex, }; +use crate::file::*; use crate::text::RenderedText; const REACTION_EMOJIS: &[&str] = &["👍", "👎", "😄", "🎉", "😕", "❤️", "🚀", "👀"]; @@ -46,6 +50,7 @@ static EMOJI_RE: LazyLock = LazyLock::new(|| Regex::new(r"^[\p{Emoji}\u{200D}\u{FE0F}\u{20E3}]+$").unwrap()); mod actions; +mod file; mod text; pub fn init(room: WeakEntity, window: &mut Window, cx: &mut App) -> Entity { @@ -96,6 +101,12 @@ pub struct ChatPanel { /// Media Attachment attachments: Entity>, + /// Uploaded, encrypted file attachments which are not sent yet + encrypted_attachments: Entity>, + + /// Decrypted attachments of file messages, by message id + decrypted_files: HashMap, + /// Upload state uploading: bool, @@ -110,6 +121,7 @@ impl ChatPanel { pub fn new(room: WeakEntity, window: &mut Window, cx: &mut Context) -> Self { // Define attachments and replies_to entities let attachments = cx.new(|_| vec![]); + let encrypted_attachments = cx.new(|_| vec![]); let replies_to = cx.new(|_| HashSet::new()); let reports_by_id = Arc::new(RwLock::new(BTreeMap::new())); @@ -185,6 +197,8 @@ impl ChatPanel { subject_bar, replies_to, attachments, + encrypted_attachments, + decrypted_files: HashMap::new(), rendered_texts_by_id: BTreeMap::new(), reports_by_id, sent_ids: Arc::new(Mutex::new(Vec::new())), @@ -370,21 +384,32 @@ impl ChatPanel { } fn send_text_message(&mut self, window: &mut Window, cx: &mut Context) { - // Get the message which includes all attachments + // Get the message which includes all plain attachments let content = self.get_input_value(cx); // Get the replies to this message let replies: Vec = self.replies_to.read(cx).iter().copied().collect(); - // Return if message is empty - if content.trim().is_empty() { + // Uploaded files are sent as encrypted file messages + let files: Vec = self + .encrypted_attachments + .read(cx) + .iter() + .map(|pending| pending.file.clone()) + .collect(); + + // Return if there is nothing to send + if content.trim().is_empty() && files.is_empty() { window.push_notification("Cannot send an empty message", cx); return; } // 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() + if replies.len() == 1 + && EMOJI_RE.is_match(&content) + && self.attachments.read(cx).is_empty() + && files.is_empty() { for reply in &replies { self.send_reaction(&content, reply, window, cx); @@ -393,7 +418,15 @@ impl ChatPanel { return; } - self.send_message(&content, replies, false, window, cx); + // Send the text part, including the plain attachment urls + if !content.trim().is_empty() { + self.send_message(&content, replies.clone(), false, window, cx); + } + + // Send every file as its own encrypted file message + for file in files { + self.send_file(file, replies.clone(), window, cx); + } } fn send_reaction( @@ -426,29 +459,59 @@ impl ChatPanel { return; } - let room = self.room.clone(); - let content = value.to_string(); - let sent_ids = self.sent_ids.clone(); - // Upgrade room and create rumor + send task in a single read lock - let Some(room_entity) = room.upgrade() else { + let Some(room) = self.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.clone(), reaction, cx)?; + let outcome = room.read_with(cx, |room, cx| { + let rumor = room.rumor(value, replies, reaction, cx)?; let send_task = room.send(rumor.clone(), cx)?; + Some((rumor, send_task)) - }) { - Some(pair) => pair, - None => { - window.push_notification("Failed to create message", cx); - return; - } + }); + + match outcome { + Some((rumor, send_task)) => self.dispatch(rumor, send_task, window, cx), + None => window.push_notification("Failed to create message", cx), + } + } + + /// Send an encrypted file message (NIP-17 kind 15) to all members of the chat + fn send_file( + &mut self, + file: FileAttachment, + replies: Vec, + window: &mut Window, + cx: &mut Context, + ) { + let Some(room) = self.room.upgrade() else { + return; }; + let outcome = room.read_with(cx, |room, cx| { + let rumor = room.file_rumor(file, replies, cx)?; + let send_task = room.send(rumor.clone(), cx)?; + + Some((rumor, send_task)) + }); + + match outcome { + Some((rumor, send_task)) => self.dispatch(rumor, send_task, window, cx), + None => window.push_notification("Failed to create message", cx), + } + } + + /// Insert a rumor optimistically and track the send reports of its gift wraps + fn dispatch( + &mut self, + rumor: UnsignedEvent, + send_task: Task>, + window: &mut Window, + cx: &mut Context, + ) { let id = rumor.id.expect("rumor must have an id"); + let sent_ids = self.sent_ids.clone(); // Insert optimistic message and clear input if rumor.kind != Kind::Reaction { @@ -487,6 +550,10 @@ impl ChatPanel { this.clear(); cx.notify(); }); + self.encrypted_attachments.update(cx, |this, cx| { + this.clear(); + cx.notify(); + }); self.replies_to.update(cx, |this, cx| { this.clear(); cx.notify(); @@ -604,7 +671,7 @@ impl ChatPanel { let Some(message) = self.message(id) else { return; }; - let content = message.content.to_string(); + let content = message.preview().to_string(); let item = ClipboardItem::new_string(content); cx.write_to_clipboard(item); @@ -630,6 +697,9 @@ impl ChatPanel { // Get the user's configured blossom server let server = AppSettings::get_file_server(cx); + // 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, @@ -646,24 +716,29 @@ impl ChatPanel { let mut paths = path.await??.context("Not found")?; let path = paths.pop().context("No path")?; - // 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); + // Upload the file, encrypted when it is the whole message + let result = if encrypted { + upload_encrypted(server, path.clone(), cx) + .await + .map(|file| Uploaded::File(file, path)) + } else { + upload(server, path, cx).await.map(Uploaded::Url) + }; + + 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) => { window.push_notification( Notification::error(e.to_string()).autohide(false), cx, ); - })?; + } } - } + })?; Ok(()) })); @@ -690,6 +765,88 @@ impl ChatPanel { }); } + fn add_pending_file(&mut self, file: FileAttachment, path: PathBuf, cx: &mut Context) { + self.encrypted_attachments.update(cx, |this, cx| { + this.push(PendingFile { file, path }); + cx.notify(); + }); + } + + fn remove_pending_file(&mut self, url: &Url, cx: &mut Context) { + self.encrypted_attachments.update(cx, |this, cx| { + if let Some(ix) = this.iter().position(|pending| &pending.file.url == url) { + this.remove(ix); + cx.notify(); + } + }); + } + + /// Download and decrypt the attachment of a file message for preview + fn load_file(&mut self, id: EventId, file: FileAttachment, cx: &mut Context) { + 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; + + this.update(cx, |this, cx| { + match result { + Ok(path) => { + this.decrypted_files.insert(id, DecryptedFile::Ready(path)); + } + Err(e) => { + this.decrypted_files + .insert(id, DecryptedFile::Failed(e.to_string().into())); + } + } + + cx.notify(); + })?; + + Ok(()) + })); + } + + /// Decrypt the attachment of a file message and open it with the OS + fn open_file( + &mut self, + id: EventId, + file: FileAttachment, + 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, + _ => {} + }; + + 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; + + 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)); + } + Err(e) => { + this.decrypted_files + .insert(id, DecryptedFile::Failed(e.to_string().into())); + } + } + + cx.notify(); + })?; + + Ok(()) + })); + } + fn profile(&self, public_key: &PublicKey, cx: &App) -> Person { let persons = PersonRegistry::global(cx); persons.read(cx).get(public_key, cx) @@ -929,6 +1086,16 @@ impl ChatPanel { window: &mut Window, cx: &mut Context, ) -> AnyElement { + let file = self.messages.get(ix).and_then(|message| { + let file = message.file.clone()?; + (!self.decrypted_files.contains_key(&message.id) && file.is_image()) + .then_some((message.id, file)) + }); + + if let Some((id, file)) = file { + self.load_file(id, file, cx); + } + if let Some(message) = self.messages.get(ix) { let persons = PersonRegistry::global(cx); let show_author = self.is_group_start(ix); @@ -1016,8 +1183,11 @@ impl ChatPanel { .when(has_replies, |this| { this.children(self.render_message_replies(replies, cx)) }) - .child(rendered_text) + .when(message.file.is_none(), |this| this.child(rendered_text)) .child(self.render_media(&message.media, cx)) + .when_some(message.file.as_ref(), |this, file| { + this.child(self.render_message_file(&id, file, cx)) + }) .when(has_reactions, |this| { this.child(self.render_reactions(&id, cx)) }), @@ -1123,7 +1293,7 @@ impl ChatPanel { .w_full() .text_ellipsis() .line_clamp(1) - .child(SharedString::from(&message.content)), + .child(message.preview()), ) .hover(|this| this.bg(cx.theme().elevated_surface_background)) .on_click({ @@ -1464,6 +1634,168 @@ impl ChatPanel { items } + /// Render the encrypted file attachment of a message + fn render_message_file( + &self, + id: &EventId, + file: &FileAttachment, + cx: &Context, + ) -> AnyElement { + let state = self.decrypted_files.get(id); + + if let Some(path) = state + .and_then(|state| match state { + DecryptedFile::Ready(path) => Some(path), + _ => None, + }) + .filter(|_| file.is_image()) + { + return div() + .child( + img(path.clone()) + .border_1() + .border_color(cx.theme().border_variant) + .h(px(250.)) + .object_fit(ObjectFit::Cover) + .rounded(cx.theme().radius), + ) + .into_any_element(); + } + + let label = match state { + Some(DecryptedFile::Loading) => SharedString::from("Decrypting..."), + Some(DecryptedFile::Failed(error)) => error.clone(), + Some(DecryptedFile::Ready(_)) => SharedString::from("Click to open"), + None => SharedString::from("Click to decrypt"), + }; + + self.render_file_chip(id, file, label, cx) + } + + /// Render an encrypted file as a chip which decrypts and opens it on click + fn render_file_chip( + &self, + id: &EventId, + file: &FileAttachment, + label: SharedString, + cx: &Context, + ) -> AnyElement { + div() + .id(SharedString::from(format!("file-{id}"))) + .flex() + .items_center() + .gap_2() + .py_1() + .px_2() + .border_1() + .border_color(cx.theme().border_variant) + .rounded(cx.theme().radius) + .hover(|this| this.bg(cx.theme().surface_background)) + .child( + Icon::new(IconName::Lock) + .small() + .text_color(cx.theme().text_placeholder), + ) + .child( + v_flex() + .flex_1() + .overflow_hidden() + .text_sm() + .child( + div() + .text_ellipsis() + .line_clamp(1) + .child(file.display_name()), + ) + .child( + div() + .text_xs() + .text_color(cx.theme().text_placeholder) + .child(label), + ), + ) + .on_click({ + let file = file.clone(); + let id = *id; + + cx.listener(move |this, _, window, cx| { + this.open_file(id, file.clone(), window, cx); + }) + }) + .into_any_element() + } + + /// Render an uploaded, encrypted file which is not sent yet + fn render_pending_file(&self, pending: &PendingFile, cx: &Context) -> impl IntoElement { + let file = &pending.file; + + div() + .id(SharedString::from(file.url.to_string())) + .relative() + .flex() + .items_center() + .gap_2() + .p_1() + .pr_2() + .border_1() + .border_color(cx.theme().border_variant) + .rounded(cx.theme().radius) + .when(file.is_image(), |this| { + this.child( + img(pending.path.clone()) + .size_8() + .rounded(cx.theme().radius) + .object_fit(ObjectFit::Cover), + ) + }) + .child( + v_flex() + .text_sm() + .child( + div() + .max_w(px(160.)) + .text_ellipsis() + .line_clamp(1) + .child(file.display_name()), + ) + .child( + h_flex() + .gap_1() + .items_center() + .text_xs() + .text_color(cx.theme().text_placeholder) + .child(Icon::new(IconName::Lock).size_2()) + .child("End-to-end encrypted"), + ), + ) + .child( + Button::new(SharedString::from(format!("remove-{}", file.url))) + .icon(IconName::Close) + .xsmall() + .ghost() + .on_click({ + let url = file.url.clone(); + cx.listener(move |this, _, _, cx| { + this.remove_pending_file(&url, cx); + }) + }), + ) + } + + fn render_pending_file_list( + &self, + _window: &Window, + cx: &Context, + ) -> impl IntoIterator { + let mut items = vec![]; + + for pending in self.encrypted_attachments.read(cx).iter() { + items.push(self.render_pending_file(pending, cx)); + } + + items + } + fn render_reply(&self, id: &EventId, cx: &Context) -> impl IntoElement { if let Some(text) = self.message(id) { let persons = PersonRegistry::global(cx); @@ -1512,7 +1844,7 @@ impl ChatPanel { .text_sm() .text_ellipsis() .line_clamp(1) - .child(SharedString::from(&text.content)), + .child(text.preview()), ) } else { div() @@ -1701,6 +2033,7 @@ impl Render for ChatPanel { .w_full() .gap_1p5() .children(self.render_attachment_list(window, cx)) + .children(self.render_pending_file_list(window, cx)) .children(self.render_reply_list(window, cx)) .child( h_flex() diff --git a/crates/state/src/file.rs b/crates/state/src/file.rs index 4649f56c..b5926583 100644 --- a/crates/state/src/file.rs +++ b/crates/state/src/file.rs @@ -9,16 +9,15 @@ use base64::engine::general_purpose::{STANDARD, STANDARD_NO_PAD, URL_SAFE, URL_S use futures::AsyncReadExt; use gpui::http_client::AsyncBody; use gpui::{AsyncApp, SharedString}; -use nostr::nips::nip94::Sha256Hash; -use nostr_sdk::prelude::*; -use sha2::{Digest, Sha256}; - #[cfg(not(target_arch = "wasm32"))] use gpui_tokio::Tokio; #[cfg(not(target_arch = "wasm32"))] use mime_guess::from_path; +use nostr::nips::nip94::Sha256Hash; #[cfg(not(target_arch = "wasm32"))] use nostr_blossom::prelude::*; +use nostr_sdk::prelude::*; +use sha2::{Digest, Sha256}; pub const ALGORITHM: &str = "aes-gcm"; @@ -243,6 +242,63 @@ pub 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())); + + 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); + } + + let data = download_and_decrypt( + &file.url, + &file.key, + &file.nonce, + file.sha256.as_deref(), + cx, + ) + .await?; + + 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(path) +} + +#[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")) +} + fn tag_value<'a>(tags: &'a Tags, name: &str) -> Option<&'a str> { tags.iter() .find(|tag| tag.kind() == name) diff --git a/crates/ui/src/icon.rs b/crates/ui/src/icon.rs index a59cb32e..3ab51480 100644 --- a/crates/ui/src/icon.rs +++ b/crates/ui/src/icon.rs @@ -46,6 +46,7 @@ pub enum IconName { InboxFill, Link, Loader, + Lock, Moon, Plus, PlusCircle, @@ -118,6 +119,7 @@ impl IconNamed for IconName { Self::InboxFill => "icons/inbox-fill.svg", Self::Link => "icons/link.svg", Self::Loader => "icons/loader.svg", + Self::Lock => "icons/lock.svg", Self::Moon => "icons/moon.svg", Self::Plus => "icons/plus.svg", Self::PlusCircle => "icons/plus-circle.svg",