use std::collections::{BTreeMap, HashMap, HashSet}; use std::path::PathBuf; use std::sync::{Arc, LazyLock, RwLock}; pub use actions::*; use anyhow::Error; use chat::{ChatRegistry, Message, Room, RoomEvent, SendReport, SendStatus}; use common::TimestampExt; use futures::lock::Mutex; use gpui::prelude::FluentBuilder; use gpui::{ AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, ListAlignment, ListOffset, ListState, MouseButton, ObjectFit, ParentElement, PathPromptOptions, Render, SharedString, SharedUri, StatefulInteractiveElement, Styled, StyledImage, Subscription, SystemNotification, SystemNotificationAction, Task, WeakEntity, Window, div, img, list, px, red, relative, retain_all, svg, white, }; use itertools::Itertools; use nostr_sdk::prelude::*; use person::{Person, PersonRegistry}; use regex::Regex; use settings::{AppSettings, SignerKind}; use smallvec::{SmallVec, smallvec}; use state::{ FileAttachment, NostrRegistry, download_and_decrypt_to_file, upload, upload_encrypted, }; use theme::ActiveTheme; use ui::avatar::Avatar; use ui::button::{Button, ButtonVariants}; use ui::dock::{Panel, PanelEvent}; use ui::input::{Input, InputEvent, InputState}; use ui::menu::DropdownMenu; use ui::notification::Notification; use ui::scroll::Scrollbar; use ui::tooltip::Tooltip; use ui::{ Disableable, Icon, IconName, InteractiveElementExt, Sizable, StyledExt, WindowExtension, h_flex, v_flex, }; use crate::file::*; use crate::text::RenderedText; const REACTION_EMOJIS: &[&str] = &["👍", "👎", "😄", "🎉", "😕", "❤️", "🚀", "👀"]; const COMPACT_REACTION_EMOJIS: &[&str] = &["👍", "❤️", "👀"]; /// Regex matching strings that consist entirely of emoji characters, /// zero-width joiners, variation selectors, and keycap combiners. 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 { cx.new(|cx| ChatPanel::new(room, window, cx)) } /// Chat Panel pub struct ChatPanel { id: SharedString, focus_handle: FocusHandle, /// Chat Room room: WeakEntity, /// Message list state list_state: ListState, /// All messages (sorted by created_at) messages: Vec, /// O(1) message lookup by EventId message_index: HashMap, /// All reactions reactions: BTreeMap>, /// Mapping message ids to their rendered texts rendered_texts_by_id: BTreeMap, /// Mapping message (rumor event) ids to their reports reports_by_id: Arc>>>, /// Chat input state input: Entity, /// Subject input state subject_input: Entity, /// Subject bar visibility subject_bar: Entity, /// Sent message ids sent_ids: Arc>>, /// Replies to replies_to: Entity>, /// 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, /// Async operations tasks: Vec>>, /// Event subscriptions subscriptions: SmallVec<[Subscription; 3]>, } 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())); // Define list of messages let messages = Vec::new(); let list_state = ListState::new(messages.len(), ListAlignment::Bottom, px(1024.)); // Get room id and name let (id, name) = room .read_with(cx, |this, _cx| { let id = this.id.to_string().into(); let name = this.display_name(cx); (id, name) }) .unwrap_or(("Unknown".into(), "Message...".into())); // Define input state let input = cx.new(|cx| { InputState::new(window, cx) .placeholder(format!("Message {}", name)) .auto_grow(1, 20) .clean_on_escape() }); // Define subject input state let subject_input = cx.new(|cx| InputState::new(window, cx).placeholder("New subject...")); let subject_bar = cx.new(|_cx| false); // Define subscriptions let mut subscriptions = smallvec![]; subscriptions.push( // Subscribe the chat input event cx.subscribe_in(&input, window, move |this, _input, event, window, cx| { if let InputEvent::PressEnter { .. } = event { this.send_text_message(window, cx); }; }), ); subscriptions.push( // Subscribe the subject input event cx.subscribe_in( &subject_input, window, move |this, _input, event, window, cx| { if let InputEvent::PressEnter { .. } = event { this.change_subject(window, cx); }; }, ), ); // Define all functions that will run after the current cycle cx.defer_in(window, |this, window, cx| { this.connect(cx); this.handle_notifications(cx); this.subscribe_room_events(window, cx); this.get_messages(window, cx); }); Self { focus_handle: cx.focus_handle(), id, messages, message_index: HashMap::new(), reactions: BTreeMap::new(), room, list_state, input, subject_input, 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())), uploading: false, subscriptions, tasks: vec![], } } /// Get messaging relays and announcement for each member fn connect(&mut self, cx: &mut Context) { if let Some(room) = self.room.upgrade() { let task = room.read(cx).connect(cx); self.tasks.push(task); } } /// Handle nostr notifications fn handle_notifications(&mut self, cx: &mut Context) { let nostr = NostrRegistry::global(cx); let client = nostr.read(cx).client(); let sent_ids = self.sent_ids.clone(); let reports = self.reports_by_id.clone(); let (tx, rx) = flume::bounded::>(256); self.tasks.push(cx.background_spawn(async move { let mut notifications = client.notifications(); while let Some(notification) = notifications.next().await { if let ClientNotification::Message { message, relay_url } = notification && let RelayMessage::Ok { event_id, status, message, } = *message { let sent_ids = sent_ids.lock().await; if sent_ids.contains(&event_id) { let status = if status { SendStatus::ok(event_id, relay_url) } else { SendStatus::failed(event_id, relay_url, message.into()) }; tx.send_async(Arc::new(status)).await.ok(); } } } Ok(()) })); self.tasks.push(cx.spawn(async move |this, cx| { while let Ok(status) = rx.recv_async().await { { let mut map = reports.write().unwrap(); let status_id = match &*status { SendStatus::Ok { id, .. } => *id, SendStatus::Failed { id, .. } => *id, }; // Find the matching report and update it (exit early on first match) 'outer: for reports_list in map.values_mut() { for report in reports_list.iter_mut() { let Some(output) = report.output.as_mut() else { continue; }; if *output.id() != status_id { continue; } match &*status { SendStatus::Ok { relay, .. } => { output.success.insert(relay.clone(), EventSendStatus::Sent); } SendStatus::Failed { relay, message, .. } => { output.failed.insert(relay.clone(), message.clone()); } } break 'outer; } } } this.update(cx, |_, cx| cx.notify()).ok(); } Ok(()) })); } /// Subscribe to room events fn subscribe_room_events(&mut self, window: &mut Window, cx: &mut Context) { let Some(room) = self.room.upgrade() else { return; }; self.subscriptions.push(cx.subscribe_in( &room, window, move |this, _room, event, window, cx| { match event { RoomEvent::Incoming(message) => { if message.rumor.kind == Kind::Reaction { this.insert_reaction(&message.rumor, cx); } else { this.insert_message(message, false, cx); if !window.is_window_active() { cx.show_system_notification(SystemNotification { tag: "message".into(), title: "New Message".into(), body: "You have a new message.".into(), actions: vec![SystemNotificationAction { id: "open".into(), label: "Open".into(), }], }); } } } RoomEvent::Reload => { // Defer to avoid re-entrant read on Room while // emit_refresh holds a write lock (via refresh_rooms). cx.defer_in(window, |this, window, cx| { this.get_messages(window, cx); }); } }; }, )); } /// Load all messages belonging to this room fn get_messages(&mut self, _window: &mut Window, cx: &mut Context) { let Ok(get_messages) = self.room.read_with(cx, |this, cx| this.get_messages(cx)) else { return; }; self.tasks.push(cx.spawn(async move |this, cx| { let events = get_messages.await?; // Update message list this.update(cx, |this, cx| { this.insert_messages(&events, cx); })?; Ok(()) })); } /// Get user input content and merged all attachments if available fn get_input_value(&self, cx: &Context) -> String { // Get input's value let mut content = self.input.read(cx).value().trim().to_string(); // Get all attaches and merge its with message let attachments = self.attachments.read(cx); if !attachments.is_empty() { let urls = attachments .iter() .map(|url| url.to_string()) .collect_vec() .join("\n"); if content.is_empty() { content = urls; } else { content = format!("{content}\n{urls}"); } } content } fn change_subject(&mut self, _window: &mut Window, cx: &mut Context) { let subject = self.subject_input.read(cx).value(); self.room .update(cx, |this, cx| { this.set_subject(subject, cx); }) .ok(); } fn send_text_message(&mut self, window: &mut Window, cx: &mut Context) { // 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(); // 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() && files.is_empty() { for reply in &replies { self.send_reaction(&content, reply, window, cx); } self.clear(window, cx); return; } // 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( &mut self, emoji: &str, target: &EventId, window: &mut Window, cx: &mut Context, ) { // Return if emoji is empty if emoji.trim().is_empty() { window.push_notification("Cannot send an empty reaction", cx); return; } self.send_message(emoji, vec![*target], true, window, cx); } /// Send a message to all members of the chat fn send_message( &mut self, value: &str, replies: Vec, reaction: bool, window: &mut Window, cx: &mut Context, ) { if value.trim().is_empty() { window.push_notification("Cannot send an empty message", cx); return; } // Upgrade room and create rumor + send task in a single read lock let Some(room) = self.room.upgrade() else { return; }; 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)) }); 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 { self.insert_message(&rumor, true, cx); self.clear(window, cx); } else { self.insert_reaction(&rumor, cx); } // Update reports self.insert_reports(id, vec![], cx); // Spawn a single task to await the send and update reports self.tasks.push(cx.spawn_in(window, async move |this, cx| { let outputs = send_task.await; let mut sent_ids = sent_ids.lock().await; sent_ids.extend(outputs.iter().filter_map(|output| output.gift_wrap_id)); this.update(cx, |this, cx| { this.insert_reports(id, outputs, cx); })?; Ok(()) })); } /// Clear the input field, attachments, and replies /// /// Only run after sending a message fn clear(&mut self, window: &mut Window, cx: &mut Context) { self.input.update(cx, |this, cx| { this.set_value("", window, cx); }); self.attachments.update(cx, |this, cx| { 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(); }) } /// Insert reports fn insert_reports(&mut self, id: EventId, reports: Vec, cx: &mut Context) { self.reports_by_id .write() .unwrap() .entry(id) .or_default() .extend(reports); cx.notify(); } /// Insert a message into the chat panel fn insert_message(&mut self, m: E, scroll: bool, cx: &mut Context) where E: Into, { let old_len = self.messages.len(); let msg: Message = m.into(); if let Err(pos) = self.messages.binary_search(&msg) { self.messages.insert(pos, msg); // Rebuild message index after insertion (indices from pos to end shift) for (i, message) in self.messages.iter().enumerate().skip(pos) { self.message_index.insert(message.id, i); } self.list_state.splice(old_len..old_len, 1); if scroll { self.list_state.scroll_to(ListOffset { item_ix: self.list_state.item_count(), offset_in_item: px(0.0), }); } cx.notify(); } } /// Convert and insert a vector of nostr events into the chat panel fn insert_messages(&mut self, events: &[UnsignedEvent], cx: &mut Context) { for event in events.iter() { if event.kind == Kind::Reaction { self.insert_reaction(event, cx); continue; } // Bulk inserting messages, so no need to scroll to the latest message self.insert_message(event, false, cx); } } /// Insert a reaction into the chat panel fn insert_reaction(&mut self, event: &UnsignedEvent, cx: &mut Context) { if event.kind != Kind::Reaction { return; } for id in event.tags.event_ids() { self.reactions .entry(id) .or_default() .push((SharedString::from(&event.content), event.pubkey)); } cx.notify(); } /// Check if a message has any reports fn has_reports(&self, id: &EventId) -> bool { self.reports_by_id.read().unwrap().contains_key(id) } /// Clone reports for a message (used for modal display, not called during render) fn sent_reports(&self, id: &EventId) -> Option> { self.reports_by_id.read().unwrap().get(id).cloned() } /// Get a message by its ID (O(1) lookup) fn message(&self, id: &EventId) -> Option<&Message> { self.message_index .get(id) .and_then(|&ix| self.messages.get(ix)) } /// Get a reaction by its target ID (returns reference, no allocation) fn reaction(&self, id: &EventId) -> &[(SharedString, PublicKey)] { self.reactions.get(id).map(|v| v.as_slice()).unwrap_or(&[]) } /// Check if a message has any reactions fn has_reaction(&self, id: &EventId) -> bool { self.reactions.contains_key(id) } /// Scroll to a message by its ID fn scroll_to(&self, id: &EventId) { if let Some(ix) = self.messages.iter().position(|msg| &msg.id == id) { self.list_state.scroll_to_reveal_item(ix); } } fn copy_author(&self, public_key: &PublicKey, cx: &App) { let content = public_key.to_bech32().unwrap(); let item = ClipboardItem::new_string(content); cx.write_to_clipboard(item); } fn copy_message(&self, id: &EventId, cx: &App) { let Some(message) = self.message(id) else { return; }; let content = message.preview().to_string(); let item = ClipboardItem::new_string(content); cx.write_to_clipboard(item); } fn reply_to(&mut self, id: &EventId, cx: &mut Context) { if let Some(text) = self.message(id) { self.replies_to.update(cx, |this, cx| { this.insert(text.id); cx.notify(); }); } } fn remove_reply(&mut self, id: &EventId, cx: &mut Context) { self.replies_to.update(cx, |this, cx| { this.remove(id); cx.notify(); }); } fn upload(&mut self, window: &mut Window, cx: &mut Context) { // 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, directories: false, multiple: false, prompt: None, }); self.tasks.push(cx.spawn_in(window, async move |this, cx| { // Selecting no file means the prompt was cancelled let Some(path) = path.await??.and_then(|mut paths| paths.pop()) else { return Ok(()); }; this.update_in(cx, |this, window, cx| { this.upload_file(server, path, encrypted, window, cx); })?; Ok(()) })); } /// Upload a file, encrypted when the attachment is the whole message fn upload_file( &mut self, server: Url, path: PathBuf, encrypted: bool, window: &mut Window, cx: &mut Context, ) { self.set_uploading(true, cx); self.tasks.push(cx.spawn_in(window, async move |this, cx| { let result = if encrypted { upload_encrypted(server.clone(), path.clone(), cx) .await .map(|file| Uploaded::File(file, path.clone())) } else { upload(server.clone(), path.clone(), 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) if encrypted => { this.report_encrypted_upload_error(server, path, e, window, cx) } Err(e) => { window.push_notification( Notification::error(e.to_string()).autohide(false), cx, ); } } })?; Ok(()) })); } /// Report a failed encrypted upload, offering to retry it without encryption fn report_encrypted_upload_error( &mut self, server: Url, path: PathBuf, error: Error, window: &mut Window, cx: &mut Context, ) { let view = cx.entity().downgrade(); window.push_notification( Notification::error(error.to_string()) .title("Encrypted upload failed") .action(move |_this, _window, _cx| { let view = view.clone(); let server = server.clone(); let path = path.clone(); Button::new("retry-without-encryption") .label("Upload without encryption") .on_click(move |_ev, window, cx| { view.update(cx, |this, cx| { this.upload_file(server.clone(), path.clone(), false, window, cx); }) .ok(); }) }), cx, ); } fn set_uploading(&mut self, uploading: bool, cx: &mut Context) { self.uploading = uploading; cx.notify(); } fn add_attachment(&mut self, url: Url, cx: &mut Context) { self.attachments.update(cx, |this, cx| { this.push(url); cx.notify(); }); } fn remove_attachment(&mut self, url: &Url, _window: &mut Window, cx: &mut Context) { self.attachments.update(cx, |this, cx| { if let Some(ix) = this.iter().position(|this| this == url) { this.remove(ix); cx.notify(); } }); } 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) } fn on_command(&mut self, command: &Command, window: &mut Window, cx: &mut Context) { match command { Command::Insert(content) => { self.input.update(cx, |this, cx| { let new_value = format!("{} {}", this.value(), content); this.set_value(new_value, window, cx); }); } Command::ChangeSubject(subject) => { if self .room .update(cx, |this, cx| { this.set_subject(subject, cx); }) .is_err() { window.push_notification(Notification::error("Failed to change subject"), cx); } } Command::ChangeSigner(kind) => { let settings = AppSettings::global(cx); let is_nip4e_enabled = settings.read(cx).is_nip4e_enabled(cx); let is_force_nip4e = *kind == SignerKind::Encryption || *kind == SignerKind::Auto; if !is_nip4e_enabled && is_force_nip4e { window.push_notification("Decoupling Encryption Key is not enabled", cx); return; } if self .room .update(cx, |this, cx| { this.set_signer_kind(kind, cx); }) .is_err() { window.push_notification(Notification::error("Failed to change signer"), cx); } } Command::ToggleBackup => { if self .room .update(cx, |this, cx| { this.set_backup(cx); }) .is_err() { window.push_notification(Notification::error("Failed to toggle backup"), cx); } } Command::Copy(public_key) => { self.copy_author(public_key, cx); } Command::Relays(public_key) => { self.open_relays(public_key, window, cx); } Command::Njump(public_key) => { self.open_njump(public_key, cx); } Command::Trace(id) => { self.open_trace(id, window, cx); } } } fn open_trace(&mut self, id: &EventId, window: &mut Window, cx: &mut Context) { let chat = ChatRegistry::global(cx); let seen_on = chat.read(cx).rumor_seen_on(id); window.open_modal(cx, move |this, _window, cx| { this.title("Seen on").show_close(true).child( v_flex() .gap_1() .when_none(&seen_on, |this| { this.child( h_flex() .h_10() .justify_center() .text_sm() .bg(cx.theme().elevated_surface_background) .rounded(cx.theme().radius) .child("Message isn't traced yet"), ) }) .when_some(seen_on.as_ref(), |this, relays| { this.children({ let mut items = vec![]; for url in relays.iter() { items.push( h_flex() .h_7() .px_2() .gap_2() .bg(cx.theme().elevated_surface_background) .rounded(cx.theme().radius) .text_sm() .child(div().size_1p5().rounded_full().bg(gpui::green())) .child(SharedString::from(url.to_string())), ); } items }) }), ) }); } fn open_relays(&mut self, public_key: &PublicKey, window: &mut Window, cx: &mut Context) { let profile = self.profile(public_key, cx); window.open_modal(cx, move |this, _window, cx| { let relays = profile.messaging_relays(); this.title("Messaging Relays") .show_close(true) .child(v_flex().gap_1().children({ let mut items = vec![]; for url in relays.iter() { items.push( h_flex() .h_7() .px_2() .gap_2() .bg(cx.theme().elevated_surface_background) .rounded(cx.theme().radius) .text_sm() .child(div().size_1p5().rounded_full().bg(gpui::green())) .child(SharedString::from(url.to_string())), ); } items })) }); } fn open_njump(&mut self, public_key: &PublicKey, cx: &mut Context) { let content = format!("https://njump.me/{}", public_key.to_bech32().unwrap()); cx.open_url(&content); } fn render_announcement(&self, cx: &Context) -> AnyElement { const MSG: &str = "This conversation is private. Only members can see each other's messages."; v_flex() .h_40() .w_full() .gap_3() .p_3() .items_center() .justify_center() .text_center() .text_xs() .text_color(cx.theme().text_placeholder) .line_height(relative(1.3)) .child( svg() .path("brand/coop.svg") .size_12() .text_color(cx.theme().ghost_element_active), ) .child(MSG) .into_any_element() } fn render_warning(&self, ix: usize, content: SharedString, cx: &Context) -> AnyElement { div() .id(ix) .w_full() .py_2() .px_3() .child( h_flex() .w_full() .gap_3() .text_sm() .child( h_flex() .flex_shrink_0() .size_8() .justify_center() .rounded_full() .bg(cx.theme().warning_background) .text_color(cx.theme().warning_foreground) .child(Icon::new(IconName::Warning).small()), ) .child( div() .flex_1() .w_full() .flex_initial() .overflow_hidden() .child(content), ), ) .into_any_element() } fn is_group_start(&self, ix: usize) -> bool { // 5 minutes const GROUP_WINDOW: u64 = 300; if ix == 0 { return true; } if let Some(previous) = self.messages.get(ix - 1) && let Some(current) = self.messages.get(ix) { if current.author != previous.author { return true; } let gap = current .created_at .as_secs() .saturating_sub(previous.created_at.as_secs()); return gap > GROUP_WINDOW; } false } fn render_message( &mut self, ix: usize, 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); let text = self .rendered_texts_by_id .entry(message.id) .or_insert_with(|| { RenderedText::new(&message.content, &message.mentions, &persons, true, cx) }) .element(ix.into(), window, cx); self.render_text_message(ix, message, text, show_author, cx) } else { self.render_warning(ix, SharedString::from("Message not found"), cx) } } fn render_text_message( &self, ix: usize, message: &Message, rendered_text: AnyElement, show_author: bool, cx: &Context, ) -> AnyElement { let id = message.id; let author = self.profile(&message.author, cx); let pk = author.public_key(); let replies = message.replies_to.as_slice(); let has_replies = !replies.is_empty(); let has_reactions = self.has_reaction(&id); let has_reports = self.has_reports(&id); // Hide avatar setting let hide_avatar = AppSettings::get_hide_avatar(cx); div() .id(ix) .group("") .relative() .w_full() .py_1() .px_3() .child( div() .flex() .gap_3() .when(!hide_avatar, |this| { if show_author { this.child( Avatar::new(author.avatar()) .flex_shrink_0() .relative() .dropdown_menu(move |this, _window, _cx| { this.menu("Public Key", Box::new(Command::Copy(pk))) .menu("View Relays", Box::new(Command::Relays(pk))) .separator() .menu("View on njump.me", Box::new(Command::Njump(pk))) }), ) } else { this.child(div().flex_shrink_0().w(px(32.))) } }) .child( v_flex() .flex_1() .w_full() .flex_initial() .overflow_hidden() .when(show_author, |this| { this.child( h_flex() .gap_2() .text_sm() .text_color(cx.theme().text_placeholder) .child(div().font_semibold().child(author.name())) .child(message.created_at.to_human_time()) .when(has_reports, |this| { this.child(self.render_sent_reports(&id, cx)) }), ) }) .when(has_replies, |this| { this.children(self.render_message_replies(replies, cx)) }) .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)) }), ), ) .child( div() .group_hover("", |this| this.bg(cx.theme().element_active)) .absolute() .left_0() .top_0() .w(px(2.)) .h_full() .bg(cx.theme().border_transparent), ) .child(self.render_actions(&id, &pk, cx)) .on_mouse_down( MouseButton::Middle, cx.listener(move |this, _, _window, cx| { this.copy_message(&id, cx); }), ) .on_double_click(cx.listener(move |this, _, _window, cx| { this.reply_to(&id, cx); })) .hover(|this| this.bg(cx.theme().surface_background)) .into_any_element() } fn render_media(&self, media: &[SharedUri], cx: &Context) -> impl IntoElement { // No media: return empty div if media.is_empty() { return div(); }; // Single media item: render full-width image if media.len() == 1 { return div().child( img(media[0].clone()) .border_1() .border_color(cx.theme().border_variant) .h(px(250.)) .object_fit(ObjectFit::Cover) .rounded(cx.theme().radius), ); } // Multiple media items: render in a row div() .w_full() .flex_1() .flex() .flex_row() .flex_wrap() .gap_2() .children({ let mut items = vec![]; for (ix, item) in media.iter().enumerate() { items.push( div() .id(format!("media-{ix}")) .flex_grow_0() .flex_shrink_0() .child( img(item.clone()) .h_32() .border_1() .border_color(cx.theme().border_variant) .rounded(cx.theme().radius), ), ); } items }) } fn render_message_replies( &self, replies: &[EventId], cx: &Context, ) -> impl IntoIterator { let mut items = Vec::with_capacity(replies.len()); for (ix, id) in replies.iter().enumerate() { let Some(message) = self.message(id) else { continue; }; let author = self.profile(&message.author, cx); items.push( div() .id(ix) .w_full() .px_2() .border_l_2() .border_color(cx.theme().element_active) .text_sm() .child(div().font_semibold().child(author.name())) .child( div() .w_full() .text_ellipsis() .line_clamp(1) .child(message.preview()), ) .hover(|this| this.bg(cx.theme().elevated_surface_background)) .on_click({ let id = *id; cx.listener(move |this, _event, _window, _cx| { this.scroll_to(&id); }) }), ); } items } fn render_reactions(&self, id: &EventId, cx: &App) -> impl IntoElement { let current_user = NostrRegistry::global(cx).read(cx).current_user(); let reactions = self.reaction(id); // Group reactions by emoji and collect authors for each let mut grouped: BTreeMap> = BTreeMap::new(); for (emoji, author) in reactions { grouped.entry(emoji.clone()).or_default().push(*author); } h_flex() .mt_2() .gap_1() .children(grouped.into_iter().map(|(emoji, authors)| { let count = authors.len(); let has_reacted = current_user .map(|pk| authors.contains(&pk)) .unwrap_or(false); h_flex() .gap_2() .py_0p5() .px_1() .rounded(cx.theme().radius) .text_xs() .border_1() .when(has_reacted, |this| { this.text_color(cx.theme().secondary_foreground) .bg(cx.theme().secondary_background) .border_color(cx.theme().secondary_active) }) .when(!has_reacted, |this| this.border_color(cx.theme().border)) .child(emoji) .child(SharedString::from(count.to_string())) })) } fn render_sent_reports(&self, id: &EventId, cx: &App) -> impl IntoElement { let reports = self.sent_reports(id); let pending = reports .as_ref() .is_some_and(|reports| reports.is_empty() || reports.iter().any(|r| r.pending())); let success = reports .as_ref() .is_some_and(|reports| !reports.is_empty() && reports.iter().any(|r| r.success())); let failed = reports .as_ref() .is_some_and(|reports| !reports.is_empty() && reports.iter().all(|r| r.failed())); let label = if success { SharedString::from("• Sent") } else if failed { SharedString::from("• Error") } else if pending { SharedString::from("• Sending...") } else { SharedString::from("• Unknown") }; div() .id(SharedString::from(id.to_hex())) .child(label) .when(failed, |this| this.text_color(cx.theme().text_danger)) .when_some(reports, |this, reports| { this.when(!pending, |this| { this.on_click(move |_e, window, cx| { let reports = reports.clone(); window.open_modal(cx, move |this, _window, cx| { this.title(SharedString::from("Sent Reports")) .show_close(true) .child(v_flex().gap_4().children({ let mut items = Vec::with_capacity(reports.len()); for report in reports.iter() { items.push(Self::render_report(report, cx)) } items })) }); }) }) }) } fn render_report(report: &SendReport, cx: &App) -> impl IntoElement { let persons = PersonRegistry::global(cx); let profile = persons.read(cx).get(&report.receiver, cx); let name = profile.name(); let avatar = profile.avatar(); v_flex() .gap_2() .w_full() .child( h_flex() .gap_2() .text_sm() .child(SharedString::from("Sent to:")) .child( h_flex() .gap_1() .font_semibold() .child(Avatar::new(avatar).small()) .child(name.clone()), ), ) .when_some(report.error.clone(), |this, error| { this.child( h_flex() .flex_wrap() .justify_center() .p_1() .h_16() .w_full() .text_sm() .rounded(cx.theme().radius) .bg(cx.theme().warning_background) .text_color(cx.theme().warning_foreground) .child(div().flex_1().w_full().text_center().child(error)), ) }) .when_some(report.output.clone(), |this, output| { this.child( v_flex() .gap_2() .w_full() .children({ let mut items = Vec::with_capacity(output.failed.len()); for (url, msg) in output.failed.into_iter() { items.push( v_flex() .gap_0p5() .p_1() .w_full() .rounded(cx.theme().radius) .bg(cx.theme().danger_background) .child( div() .text_xs() .font_semibold() .line_height(relative(1.25)) .child(SharedString::from(url.to_string())), ) .child( div() .text_xs() .text_color(cx.theme().danger_foreground) .line_height(relative(1.25)) .child(SharedString::from(msg.to_string())), ), ) } items }) .children({ let mut items = Vec::with_capacity(output.success.len()); for url in output.success.into_iter() { items.push( v_flex() .gap_0p5() .p_1() .w_full() .rounded(cx.theme().radius) .bg(cx.theme().elevated_surface_background) .child( div() .text_xs() .font_semibold() .line_height(relative(1.25)) .child(SharedString::from(url.0.to_string())), ) .child( div() .text_xs() .line_height(relative(1.25)) .child(SharedString::from("Successfully")), ), ) } items }), ) }) } fn render_actions( &self, id: &EventId, public_key: &PublicKey, cx: &Context, ) -> impl IntoElement { h_flex() .p_0p5() .gap_1() .invisible() .absolute() .right_4() .top_neg_2() .when(cx.theme().shadow, |this| this.shadow_sm()) .rounded(cx.theme().radius) .border_1() .border_color(cx.theme().border) .bg(cx.theme().background) .children({ let mut items = vec![]; for emoji in COMPACT_REACTION_EMOJIS { items.push( Button::new(*emoji) .label(*emoji) .tooltip(*emoji) .small() .ghost() .on_click({ let emoji = *emoji; let id = *id; cx.listener(move |this, _event, window, cx| { this.send_reaction(emoji, &id, window, cx); }) }), ); } items }) .child(div().flex_shrink_0().h_4().w_px().bg(cx.theme().border)) .child( Button::new("reply") .icon(IconName::Reply) .tooltip("Reply") .small() .ghost() .on_click({ let id = id.to_owned(); cx.listener(move |this, _event, _window, cx| { this.reply_to(&id, cx); }) }), ) .child( Button::new("copy") .icon(IconName::Copy) .tooltip("Copy") .small() .ghost() .on_click({ let id = id.to_owned(); cx.listener(move |this, _event, _window, cx| { this.copy_message(&id, cx); }) }), ) .child(div().flex_shrink_0().h_4().w_px().bg(cx.theme().border)) .child( Button::new("advance") .icon(IconName::Ellipsis) .small() .ghost() .dropdown_menu({ let public_key = *public_key; let id = *id; move |this, _window, _cx| { this.menu("Copy author", Box::new(Command::Copy(public_key))) .menu("Seen on", Box::new(Command::Trace(id))) } }), ) .group_hover("", |this| this.visible()) } fn render_attachment(&self, url: &Url, cx: &Context) -> impl IntoElement { div() .id(SharedString::from(url.to_string())) .relative() .w_16() .child( img(url.as_str()) .size_16() .when(cx.theme().shadow, |this| this.shadow_lg()) .rounded(cx.theme().radius) .object_fit(ObjectFit::Cover), ) .child( div() .absolute() .top_neg_2() .right_neg_2() .size_4() .flex() .items_center() .justify_center() .rounded_full() .bg(red()) .child(Icon::new(IconName::Close).size_2().text_color(white())), ) .on_click({ let url = url.clone(); cx.listener(move |this, _, window, cx| { this.remove_attachment(&url, window, cx); }) }) } fn render_attachment_list( &self, _window: &Window, cx: &Context, ) -> impl IntoIterator { let mut items = vec![]; for url in self.attachments.read(cx).iter() { items.push(self.render_attachment(url, cx)); } 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 { h_flex() .id(SharedString::from(format!("file-{id}"))) .self_start() .items_start() .min_w_0() .gap_2() .p_2() .border_1() .border_color(cx.theme().border_variant) .rounded(cx.theme().radius) .child(Icon::new(IconName::Lock).text_color(cx.theme().icon_accent)) .child( v_flex() .min_w_0() .overflow_hidden() .text_sm() .child(div().line_height(relative(1.2)).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; let label = file.display_name(); div() .id(SharedString::from(file.url.to_string())) .relative() .w_16() .tooltip(move |window, cx| Tooltip::new(label.clone(), window, cx).into()) .map(|this| { if file.is_image() { this.child( img(pending.path.clone()) .size_16() .when(cx.theme().shadow, |this| this.shadow_sm()) .rounded(cx.theme().radius) .object_fit(ObjectFit::Cover), ) } else { this.child( div() .size_16() .flex() .items_center() .justify_center() .rounded(cx.theme().radius) .border_1() .border_color(cx.theme().border_variant) .bg(cx.theme().surface_background) .text_xs() .text_center() .child("Preview not available"), ) } }) .child( v_flex() .absolute() .top_neg_1() .right_neg_1() .size_4() .items_center() .justify_center() .rounded_full() .border_1() .border_color(cx.theme().border_variant) .bg(gpui::green()) .child(Icon::new(IconName::Lock).size_2().text_color(gpui::white())), ) .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); let profile = persons.read(cx).get(&text.author, cx); div() .w_full() .pl_2() .border_l_2() .border_color(cx.theme().element_active) .child( div() .flex() .items_center() .justify_between() .child( div() .flex() .items_baseline() .gap_1() .text_xs() .text_color(cx.theme().text_muted) .child("Replying to:") .child( div() .text_color(cx.theme().text_accent) .child(profile.name()), ), ) .child( Button::new("remove-reply") .icon(IconName::Close) .xsmall() .ghost() .on_click({ let id = text.id; cx.listener(move |this, _, _, cx| { this.remove_reply(&id, cx); }) }), ), ) .child( div() .w_full() .text_sm() .text_ellipsis() .line_clamp(1) .child(text.preview()), ) } else { div() } } fn render_reply_list( &self, _window: &Window, cx: &Context, ) -> impl IntoIterator { let mut items = vec![]; for id in self.replies_to.read(cx).iter() { items.push(self.render_reply(id, cx)); } items } fn render_config_menu(&self, _window: &mut Window, cx: &Context) -> impl IntoElement { let (backup, signer_kind) = self .room .read_with(cx, |this, _cx| { (this.config().backup(), this.config().signer_kind().clone()) }) .ok() .unwrap_or((true, SignerKind::default())); Button::new("encryption") .icon(IconName::Settings2) .tooltip("Configuration") .ghost() .large() .dropdown_menu(move |this, _window, _cx| { let auto = matches!(signer_kind, SignerKind::Auto); let encryption = matches!(signer_kind, SignerKind::Encryption); let user = matches!(signer_kind, SignerKind::User); this.label("Signer") .menu_with_check_and_disabled( "Auto", auto, Box::new(Command::ChangeSigner(SignerKind::Auto)), auto, ) .menu_with_check_and_disabled( "Decoupled Encryption Key", encryption, Box::new(Command::ChangeSigner(SignerKind::Encryption)), encryption, ) .menu_with_check_and_disabled( "User Identity", user, Box::new(Command::ChangeSigner(SignerKind::User)), user, ) .separator() .label("Backup") .menu_with_check("Backup messages", backup, Box::new(Command::ToggleBackup)) }) } fn render_emoji_menu(&self, _window: &Window, _cx: &Context) -> impl IntoElement { Button::new("emoji") .icon(IconName::Emoji) .ghost() .large() .dropdown_menu_with_anchor(gpui::Anchor::BottomLeft, move |this, _window, _cx| { let menu = this.horizontal(); REACTION_EMOJIS.iter().fold(menu, |this, emoji| { this.menu(*emoji, Box::new(Command::Insert(emoji))) }) }) } } impl Panel for ChatPanel { fn panel_id(&self) -> SharedString { self.id.clone() } fn title(&self, cx: &App) -> AnyElement { self.room .read_with(cx, |this, cx| { let label = this.display_name(cx); let url = this.display_image(cx); h_flex() .gap_1p5() .child(Avatar::new(url).xsmall()) .child(label) .into_any_element() }) .unwrap_or(div().child("Unknown").into_any_element()) } fn toolbar_buttons(&self, _window: &Window, _cx: &App) -> Vec