update chat ui
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M12 8.75003C11.1716 8.75003 10.5 9.4216 10.5 10.25C10.5 11.0785 11.1716 11.75 12 11.75C12.8284 11.75 13.5 11.0785 13.5 10.25C13.5 9.4216 12.8284 8.75003 12 8.75003ZM12 8.75003V14.75M20.25 11.9124V6.94155C20.25 6.08069 19.6991 5.31641 18.8825 5.04418L12.6325 2.96085C12.2219 2.824 11.7781 2.824 11.3675 2.96085L5.11754 5.04418C4.30086 5.31641 3.75 6.08069 3.75 6.94155V11.9124C3.75 16.8848 8 19.25 12 21.4079C16 19.25 20.25 16.8848 20.25 11.9124Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 626 B |
@@ -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())
|
||||||
|
}
|
||||||
+370
-37
@@ -1,4 +1,5 @@
|
|||||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||||
|
use std::path::PathBuf;
|
||||||
use std::sync::{Arc, LazyLock, RwLock};
|
use std::sync::{Arc, LazyLock, RwLock};
|
||||||
|
|
||||||
pub use actions::*;
|
pub use actions::*;
|
||||||
@@ -21,7 +22,9 @@ use person::{Person, PersonRegistry};
|
|||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
use settings::{AppSettings, SignerKind};
|
use settings::{AppSettings, SignerKind};
|
||||||
use smallvec::{SmallVec, smallvec};
|
use smallvec::{SmallVec, smallvec};
|
||||||
use state::{NostrRegistry, upload};
|
use state::{
|
||||||
|
FileAttachment, NostrRegistry, download_and_decrypt_to_file, upload, upload_encrypted,
|
||||||
|
};
|
||||||
use theme::ActiveTheme;
|
use theme::ActiveTheme;
|
||||||
use ui::avatar::Avatar;
|
use ui::avatar::Avatar;
|
||||||
use ui::button::{Button, ButtonVariants};
|
use ui::button::{Button, ButtonVariants};
|
||||||
@@ -35,6 +38,7 @@ use ui::{
|
|||||||
h_flex, v_flex,
|
h_flex, v_flex,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use crate::file::*;
|
||||||
use crate::text::RenderedText;
|
use crate::text::RenderedText;
|
||||||
|
|
||||||
const REACTION_EMOJIS: &[&str] = &["👍", "👎", "😄", "🎉", "😕", "❤️", "🚀", "👀"];
|
const REACTION_EMOJIS: &[&str] = &["👍", "👎", "😄", "🎉", "😕", "❤️", "🚀", "👀"];
|
||||||
@@ -46,6 +50,7 @@ static EMOJI_RE: LazyLock<Regex> =
|
|||||||
LazyLock::new(|| Regex::new(r"^[\p{Emoji}\u{200D}\u{FE0F}\u{20E3}]+$").unwrap());
|
LazyLock::new(|| Regex::new(r"^[\p{Emoji}\u{200D}\u{FE0F}\u{20E3}]+$").unwrap());
|
||||||
|
|
||||||
mod actions;
|
mod actions;
|
||||||
|
mod file;
|
||||||
mod text;
|
mod text;
|
||||||
|
|
||||||
pub fn init(room: WeakEntity<Room>, window: &mut Window, cx: &mut App) -> Entity<ChatPanel> {
|
pub fn init(room: WeakEntity<Room>, window: &mut Window, cx: &mut App) -> Entity<ChatPanel> {
|
||||||
@@ -96,6 +101,12 @@ pub struct ChatPanel {
|
|||||||
/// Media Attachment
|
/// Media Attachment
|
||||||
attachments: Entity<Vec<Url>>,
|
attachments: Entity<Vec<Url>>,
|
||||||
|
|
||||||
|
/// Uploaded, encrypted file attachments which are not sent yet
|
||||||
|
encrypted_attachments: Entity<Vec<PendingFile>>,
|
||||||
|
|
||||||
|
/// Decrypted attachments of file messages, by message id
|
||||||
|
decrypted_files: HashMap<EventId, DecryptedFile>,
|
||||||
|
|
||||||
/// Upload state
|
/// Upload state
|
||||||
uploading: bool,
|
uploading: bool,
|
||||||
|
|
||||||
@@ -110,6 +121,7 @@ impl ChatPanel {
|
|||||||
pub fn new(room: WeakEntity<Room>, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
pub fn new(room: WeakEntity<Room>, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||||
// Define attachments and replies_to entities
|
// Define attachments and replies_to entities
|
||||||
let attachments = cx.new(|_| vec![]);
|
let attachments = cx.new(|_| vec![]);
|
||||||
|
let encrypted_attachments = cx.new(|_| vec![]);
|
||||||
let replies_to = cx.new(|_| HashSet::new());
|
let replies_to = cx.new(|_| HashSet::new());
|
||||||
let reports_by_id = Arc::new(RwLock::new(BTreeMap::new()));
|
let reports_by_id = Arc::new(RwLock::new(BTreeMap::new()));
|
||||||
|
|
||||||
@@ -185,6 +197,8 @@ impl ChatPanel {
|
|||||||
subject_bar,
|
subject_bar,
|
||||||
replies_to,
|
replies_to,
|
||||||
attachments,
|
attachments,
|
||||||
|
encrypted_attachments,
|
||||||
|
decrypted_files: HashMap::new(),
|
||||||
rendered_texts_by_id: BTreeMap::new(),
|
rendered_texts_by_id: BTreeMap::new(),
|
||||||
reports_by_id,
|
reports_by_id,
|
||||||
sent_ids: Arc::new(Mutex::new(Vec::new())),
|
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<Self>) {
|
fn send_text_message(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
// Get the message which includes all attachments
|
// Get the message which includes all plain attachments
|
||||||
let content = self.get_input_value(cx);
|
let content = self.get_input_value(cx);
|
||||||
|
|
||||||
// Get the replies to this message
|
// Get the replies to this message
|
||||||
let replies: Vec<EventId> = self.replies_to.read(cx).iter().copied().collect();
|
let replies: Vec<EventId> = self.replies_to.read(cx).iter().copied().collect();
|
||||||
|
|
||||||
// Return if message is empty
|
// Uploaded files are sent as encrypted file messages
|
||||||
if content.trim().is_empty() {
|
let files: Vec<FileAttachment> = 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);
|
window.push_notification("Cannot send an empty message", cx);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If replying to exactly one message with only a valid emoji,
|
// If replying to exactly one message with only a valid emoji,
|
||||||
// send as a reaction instead of a text message
|
// 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 {
|
for reply in &replies {
|
||||||
self.send_reaction(&content, reply, window, cx);
|
self.send_reaction(&content, reply, window, cx);
|
||||||
@@ -393,7 +418,15 @@ impl ChatPanel {
|
|||||||
return;
|
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(
|
fn send_reaction(
|
||||||
@@ -426,29 +459,59 @@ impl ChatPanel {
|
|||||||
return;
|
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
|
// 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;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create rumor and send task
|
let outcome = room.read_with(cx, |room, cx| {
|
||||||
let (rumor, send_task) = match room_entity.read_with(cx, |room, cx| {
|
let rumor = room.rumor(value, replies, reaction, cx)?;
|
||||||
let rumor = room.rumor(content.clone(), replies.clone(), reaction, cx)?;
|
|
||||||
let send_task = room.send(rumor.clone(), cx)?;
|
let send_task = room.send(rumor.clone(), cx)?;
|
||||||
|
|
||||||
Some((rumor, send_task))
|
Some((rumor, send_task))
|
||||||
}) {
|
});
|
||||||
Some(pair) => pair,
|
|
||||||
None => {
|
match outcome {
|
||||||
window.push_notification("Failed to create message", cx);
|
Some((rumor, send_task)) => self.dispatch(rumor, send_task, window, cx),
|
||||||
return;
|
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<EventId>,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) {
|
||||||
|
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<Vec<SendReport>>,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) {
|
||||||
let id = rumor.id.expect("rumor must have an id");
|
let id = rumor.id.expect("rumor must have an id");
|
||||||
|
let sent_ids = self.sent_ids.clone();
|
||||||
|
|
||||||
// Insert optimistic message and clear input
|
// Insert optimistic message and clear input
|
||||||
if rumor.kind != Kind::Reaction {
|
if rumor.kind != Kind::Reaction {
|
||||||
@@ -487,6 +550,10 @@ impl ChatPanel {
|
|||||||
this.clear();
|
this.clear();
|
||||||
cx.notify();
|
cx.notify();
|
||||||
});
|
});
|
||||||
|
self.encrypted_attachments.update(cx, |this, cx| {
|
||||||
|
this.clear();
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
self.replies_to.update(cx, |this, cx| {
|
self.replies_to.update(cx, |this, cx| {
|
||||||
this.clear();
|
this.clear();
|
||||||
cx.notify();
|
cx.notify();
|
||||||
@@ -604,7 +671,7 @@ impl ChatPanel {
|
|||||||
let Some(message) = self.message(id) else {
|
let Some(message) = self.message(id) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let content = message.content.to_string();
|
let content = message.preview().to_string();
|
||||||
let item = ClipboardItem::new_string(content);
|
let item = ClipboardItem::new_string(content);
|
||||||
|
|
||||||
cx.write_to_clipboard(item);
|
cx.write_to_clipboard(item);
|
||||||
@@ -630,6 +697,9 @@ impl ChatPanel {
|
|||||||
// Get the user's configured blossom server
|
// Get the user's configured blossom server
|
||||||
let server = AppSettings::get_file_server(cx);
|
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
|
// Ask user for file upload
|
||||||
let path = cx.prompt_for_paths(PathPromptOptions {
|
let path = cx.prompt_for_paths(PathPromptOptions {
|
||||||
files: true,
|
files: true,
|
||||||
@@ -646,24 +716,29 @@ impl ChatPanel {
|
|||||||
let mut paths = path.await??.context("Not found")?;
|
let mut paths = path.await??.context("Not found")?;
|
||||||
let path = paths.pop().context("No path")?;
|
let path = paths.pop().context("No path")?;
|
||||||
|
|
||||||
// Upload via blossom client
|
// Upload the file, encrypted when it is the whole message
|
||||||
match upload(server, path, cx).await {
|
let result = if encrypted {
|
||||||
Ok(url) => {
|
upload_encrypted(server, path.clone(), cx)
|
||||||
this.update_in(cx, |this, _window, cx| {
|
.await
|
||||||
this.add_attachment(url, cx);
|
.map(|file| Uploaded::File(file, path))
|
||||||
this.set_uploading(false, cx);
|
} else {
|
||||||
})?;
|
upload(server, path, cx).await.map(Uploaded::Url)
|
||||||
}
|
};
|
||||||
Err(e) => {
|
|
||||||
this.update_in(cx, |this, window, cx| {
|
this.update_in(cx, |this, window, cx| {
|
||||||
this.set_uploading(false, 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(
|
window.push_notification(
|
||||||
Notification::error(e.to_string()).autohide(false),
|
Notification::error(e.to_string()).autohide(false),
|
||||||
cx,
|
cx,
|
||||||
);
|
);
|
||||||
})?;
|
}
|
||||||
}
|
}
|
||||||
}
|
})?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}));
|
}));
|
||||||
@@ -690,6 +765,88 @@ impl ChatPanel {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn add_pending_file(&mut self, file: FileAttachment, path: PathBuf, cx: &mut Context<Self>) {
|
||||||
|
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>) {
|
||||||
|
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>) {
|
||||||
|
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<Self>,
|
||||||
|
) {
|
||||||
|
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 {
|
fn profile(&self, public_key: &PublicKey, cx: &App) -> Person {
|
||||||
let persons = PersonRegistry::global(cx);
|
let persons = PersonRegistry::global(cx);
|
||||||
persons.read(cx).get(public_key, cx)
|
persons.read(cx).get(public_key, cx)
|
||||||
@@ -929,6 +1086,16 @@ impl ChatPanel {
|
|||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) -> AnyElement {
|
) -> 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) {
|
if let Some(message) = self.messages.get(ix) {
|
||||||
let persons = PersonRegistry::global(cx);
|
let persons = PersonRegistry::global(cx);
|
||||||
let show_author = self.is_group_start(ix);
|
let show_author = self.is_group_start(ix);
|
||||||
@@ -1016,8 +1183,11 @@ impl ChatPanel {
|
|||||||
.when(has_replies, |this| {
|
.when(has_replies, |this| {
|
||||||
this.children(self.render_message_replies(replies, cx))
|
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))
|
.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| {
|
.when(has_reactions, |this| {
|
||||||
this.child(self.render_reactions(&id, cx))
|
this.child(self.render_reactions(&id, cx))
|
||||||
}),
|
}),
|
||||||
@@ -1123,7 +1293,7 @@ impl ChatPanel {
|
|||||||
.w_full()
|
.w_full()
|
||||||
.text_ellipsis()
|
.text_ellipsis()
|
||||||
.line_clamp(1)
|
.line_clamp(1)
|
||||||
.child(SharedString::from(&message.content)),
|
.child(message.preview()),
|
||||||
)
|
)
|
||||||
.hover(|this| this.bg(cx.theme().elevated_surface_background))
|
.hover(|this| this.bg(cx.theme().elevated_surface_background))
|
||||||
.on_click({
|
.on_click({
|
||||||
@@ -1464,6 +1634,168 @@ impl ChatPanel {
|
|||||||
items
|
items
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Render the encrypted file attachment of a message
|
||||||
|
fn render_message_file(
|
||||||
|
&self,
|
||||||
|
id: &EventId,
|
||||||
|
file: &FileAttachment,
|
||||||
|
cx: &Context<Self>,
|
||||||
|
) -> 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<Self>,
|
||||||
|
) -> 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<Self>) -> 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<Self>,
|
||||||
|
) -> impl IntoIterator<Item = impl IntoElement> {
|
||||||
|
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<Self>) -> impl IntoElement {
|
fn render_reply(&self, id: &EventId, cx: &Context<Self>) -> impl IntoElement {
|
||||||
if let Some(text) = self.message(id) {
|
if let Some(text) = self.message(id) {
|
||||||
let persons = PersonRegistry::global(cx);
|
let persons = PersonRegistry::global(cx);
|
||||||
@@ -1512,7 +1844,7 @@ impl ChatPanel {
|
|||||||
.text_sm()
|
.text_sm()
|
||||||
.text_ellipsis()
|
.text_ellipsis()
|
||||||
.line_clamp(1)
|
.line_clamp(1)
|
||||||
.child(SharedString::from(&text.content)),
|
.child(text.preview()),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
div()
|
div()
|
||||||
@@ -1701,6 +2033,7 @@ impl Render for ChatPanel {
|
|||||||
.w_full()
|
.w_full()
|
||||||
.gap_1p5()
|
.gap_1p5()
|
||||||
.children(self.render_attachment_list(window, cx))
|
.children(self.render_attachment_list(window, cx))
|
||||||
|
.children(self.render_pending_file_list(window, cx))
|
||||||
.children(self.render_reply_list(window, cx))
|
.children(self.render_reply_list(window, cx))
|
||||||
.child(
|
.child(
|
||||||
h_flex()
|
h_flex()
|
||||||
|
|||||||
@@ -9,16 +9,15 @@ use base64::engine::general_purpose::{STANDARD, STANDARD_NO_PAD, URL_SAFE, URL_S
|
|||||||
use futures::AsyncReadExt;
|
use futures::AsyncReadExt;
|
||||||
use gpui::http_client::AsyncBody;
|
use gpui::http_client::AsyncBody;
|
||||||
use gpui::{AsyncApp, SharedString};
|
use gpui::{AsyncApp, SharedString};
|
||||||
use nostr::nips::nip94::Sha256Hash;
|
|
||||||
use nostr_sdk::prelude::*;
|
|
||||||
use sha2::{Digest, Sha256};
|
|
||||||
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
use gpui_tokio::Tokio;
|
use gpui_tokio::Tokio;
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
use mime_guess::from_path;
|
use mime_guess::from_path;
|
||||||
|
use nostr::nips::nip94::Sha256Hash;
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
use nostr_blossom::prelude::*;
|
use nostr_blossom::prelude::*;
|
||||||
|
use nostr_sdk::prelude::*;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
pub const ALGORITHM: &str = "aes-gcm";
|
pub const ALGORITHM: &str = "aes-gcm";
|
||||||
|
|
||||||
@@ -243,6 +242,63 @@ pub async fn download_and_decrypt(
|
|||||||
decrypt(&data, key, nonce)
|
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<PathBuf, Error> {
|
||||||
|
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<PathBuf, Error> {
|
||||||
|
Err(anyhow!("File download not supported on web"))
|
||||||
|
}
|
||||||
|
|
||||||
fn tag_value<'a>(tags: &'a Tags, name: &str) -> Option<&'a str> {
|
fn tag_value<'a>(tags: &'a Tags, name: &str) -> Option<&'a str> {
|
||||||
tags.iter()
|
tags.iter()
|
||||||
.find(|tag| tag.kind() == name)
|
.find(|tag| tag.kind() == name)
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ pub enum IconName {
|
|||||||
InboxFill,
|
InboxFill,
|
||||||
Link,
|
Link,
|
||||||
Loader,
|
Loader,
|
||||||
|
Lock,
|
||||||
Moon,
|
Moon,
|
||||||
Plus,
|
Plus,
|
||||||
PlusCircle,
|
PlusCircle,
|
||||||
@@ -118,6 +119,7 @@ impl IconNamed for IconName {
|
|||||||
Self::InboxFill => "icons/inbox-fill.svg",
|
Self::InboxFill => "icons/inbox-fill.svg",
|
||||||
Self::Link => "icons/link.svg",
|
Self::Link => "icons/link.svg",
|
||||||
Self::Loader => "icons/loader.svg",
|
Self::Loader => "icons/loader.svg",
|
||||||
|
Self::Lock => "icons/lock.svg",
|
||||||
Self::Moon => "icons/moon.svg",
|
Self::Moon => "icons/moon.svg",
|
||||||
Self::Plus => "icons/plus.svg",
|
Self::Plus => "icons/plus.svg",
|
||||||
Self::PlusCircle => "icons/plus-circle.svg",
|
Self::PlusCircle => "icons/plus-circle.svg",
|
||||||
|
|||||||
Reference in New Issue
Block a user