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",