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
+3
View File
@@ -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

+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()
+60 -4
View File
@@ -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<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> {
tags.iter()
.find(|tag| tag.kind() == name)
+2
View File
@@ -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",