update chat ui

This commit is contained in:
2026-09-15 20:05:27 +07:00
parent 2d7881e574
commit 8303b054f9
5 changed files with 467 additions and 41 deletions
+32
View File
@@ -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
View File
@@ -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<Regex> =
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<Room>, window: &mut Window, cx: &mut App) -> Entity<ChatPanel> {
@@ -96,6 +101,12 @@ pub struct ChatPanel {
/// Media Attachment
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
uploading: bool,
@@ -110,6 +121,7 @@ impl ChatPanel {
pub fn new(room: WeakEntity<Room>, window: &mut Window, cx: &mut Context<Self>) -> 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<Self>) {
// 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<EventId> = 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<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);
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<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 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>) {
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 {
let persons = PersonRegistry::global(cx);
persons.read(cx).get(public_key, cx)
@@ -929,6 +1086,16 @@ impl ChatPanel {
window: &mut Window,
cx: &mut Context<Self>,
) -> 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<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 {
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()