use std::collections::HashSet; use std::time::Duration; pub use actions::*; use chat::{Message, RenderedMessage, Room, RoomKind, RoomSignal, SendOptions, SendReport}; use common::{nip96_upload, RenderedProfile, RenderedTimestamp}; use gpui::prelude::FluentBuilder; use gpui::{ div, img, list, px, red, relative, rems, svg, white, AnyElement, App, AppContext, ClipboardItem, Context, Element, Entity, EventEmitter, Flatten, FocusHandle, Focusable, InteractiveElement, IntoElement, ListAlignment, ListOffset, ListState, MouseButton, ObjectFit, ParentElement, PathPromptOptions, Render, RetainAllImageCache, SharedString, StatefulInteractiveElement, Styled, StyledImage, Subscription, Task, Window, }; use gpui_tokio::Tokio; use indexset::{BTreeMap, BTreeSet}; use itertools::Itertools; use nostr_sdk::prelude::*; use person::PersonRegistry; use settings::AppSettings; use smallvec::{smallvec, SmallVec}; use smol::fs; use state::NostrRegistry; use theme::ActiveTheme; use ui::avatar::Avatar; use ui::button::{Button, ButtonVariants}; use ui::context_menu::ContextMenuExt; use ui::dock_area::panel::{Panel, PanelEvent}; use ui::input::{InputEvent, InputState, TextInput}; use ui::modal::ModalButtonProps; use ui::notification::Notification; use ui::popup_menu::PopupMenuExt; use ui::{ h_flex, v_flex, ContextModal, Disableable, Icon, IconName, InteractiveElementExt, Sizable, StyledExt, }; use crate::emoji::EmojiPicker; use crate::text::RenderedText; mod actions; mod emoji; mod subject; mod text; pub fn init(room: Entity, window: &mut Window, cx: &mut App) -> Entity { cx.new(|cx| ChatPanel::new(room, window, cx)) } pub struct ChatPanel { // Chat Room room: Entity, // Messages list_state: ListState, messages: BTreeSet, rendered_texts_by_id: BTreeMap, reports_by_id: BTreeMap>, // New Message input: Entity, options: Entity, replies_to: Entity>, // Media Attachment attachments: Entity>, uploading: bool, // Panel id: SharedString, focus_handle: FocusHandle, image_cache: Entity, _subscriptions: SmallVec<[Subscription; 3]>, _tasks: SmallVec<[Task<()>; 2]>, } impl ChatPanel { pub fn new(room: Entity, window: &mut Window, cx: &mut Context) -> Self { let input = cx.new(|cx| { InputState::new(window, cx) .placeholder("Message...") .auto_grow(1, 20) .prevent_new_line_on_enter() .clean_on_escape() }); let attachments = cx.new(|_| vec![]); let replies_to = cx.new(|_| HashSet::new()); let options = cx.new(|_| SendOptions::default()); let id = room.read(cx).id.to_string().into(); let messages = BTreeSet::from([Message::system()]); let list_state = ListState::new(messages.len(), ListAlignment::Bottom, px(1024.)); let connect = room.read(cx).connect(cx); let get_messages = room.read(cx).get_messages(cx); let mut subscriptions = smallvec![]; let mut tasks = smallvec![]; tasks.push( // Get messaging relays and encryption keys announcement for each member cx.background_spawn(async move { if let Err(e) = connect.await { log::error!("Failed to initialize room: {}", e); } }), ); tasks.push( // Load all messages belonging to this room cx.spawn_in(window, async move |this, cx| { let result = get_messages.await; this.update_in(cx, |this, window, cx| { match result { Ok(events) => { this.insert_messages(events, cx); } Err(e) => { window.push_notification(e.to_string(), cx); } }; }) .ok(); }), ); subscriptions.push( // Subscribe to input events cx.subscribe_in( &input, window, move |this: &mut Self, _input, event, window, cx| { if let InputEvent::PressEnter { .. } = event { this.send_message(window, cx); }; }, ), ); subscriptions.push( // Subscribe to room events cx.subscribe_in(&room, window, move |this, _, signal, window, cx| { match signal { RoomSignal::NewMessage((gift_wrap_id, event)) => { let nostr = NostrRegistry::global(cx); let tracker = nostr.read(cx).tracker(); let gift_wrap_id = gift_wrap_id.to_owned(); let message = Message::user(event.clone()); cx.spawn_in(window, async move |this, cx| { let tracker = tracker.read().await; this.update_in(cx, |this, _window, cx| { if !tracker.sent_ids().contains(&gift_wrap_id) { this.insert_message(message, false, cx); } }) .ok(); }) .detach(); } RoomSignal::Refresh => { this.load_messages(window, cx); } }; }), ); subscriptions.push( // Observe when user close chat panel cx.on_release_in(window, move |this, window, cx| { this.messages.clear(); this.rendered_texts_by_id.clear(); this.reports_by_id.clear(); this.image_cache.update(cx, |this, cx| { this.clear(window, cx); }); }), ); Self { id, messages, room, list_state, input, replies_to, attachments, options, rendered_texts_by_id: BTreeMap::new(), reports_by_id: BTreeMap::new(), uploading: false, image_cache: RetainAllImageCache::new(cx), focus_handle: cx.focus_handle(), _subscriptions: subscriptions, _tasks: tasks, } } /// Load all messages belonging to this room fn load_messages(&mut self, window: &mut Window, cx: &mut Context) { let get_messages = self.room.read(cx).get_messages(cx); self._tasks.push( // Run the task in the background cx.spawn_in(window, async move |this, cx| { let result = get_messages.await; this.update_in(cx, |this, window, cx| { match result { Ok(events) => { this.insert_messages(events, cx); } Err(e) => { window.push_notification(Notification::error(e.to_string()), cx); } }; }) .ok(); }), ); } /// Get user input content and merged all attachments fn input_content(&self, cx: &Context) -> String { 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 } /// Send a message to all members of the chat fn send_message(&mut self, window: &mut Window, cx: &mut Context) { // Get the message which includes all attachments let content = self.input_content(cx); // Return if message is empty if content.trim().is_empty() { window.push_notification("Cannot send an empty message", cx); return; } // Temporary disable the message input self.input.update(cx, |this, cx| { this.set_loading(false, cx); this.set_disabled(false, cx); this.set_value("", window, cx); }); // Get replies_to if it's present let replies: Vec = self.replies_to.read(cx).iter().copied().collect(); // Get the current room entity let room = self.room.read(cx); let opts = self.options.read(cx); // Create a temporary message for optimistic update let rumor = room.create_message(&content, replies.as_ref(), cx); let rumor_id = rumor.id.unwrap(); // Create a task for sending the message in the background let send_message = room.send_message(&rumor, opts, cx); // Optimistically update message list cx.spawn_in(window, async move |this, cx| { // Wait for the delay cx.background_executor() .timer(Duration::from_millis(100)) .await; // Update the message list and reset the states this.update_in(cx, |this, window, cx| { this.insert_message(Message::user(rumor), true, cx); this.remove_all_replies(cx); this.remove_all_attachments(cx); this.input.update(cx, |this, cx| { this.set_loading(false, cx); this.set_disabled(false, cx); this.set_value("", window, cx); }); }) .ok(); }) .detach(); self._tasks.push( // Continue sending the message in the background cx.spawn_in(window, async move |this, cx| { let result = send_message.await; this.update_in(cx, |this, window, cx| { match result { Ok(reports) => { // Update room's status this.room.update(cx, |this, cx| { if this.kind != RoomKind::Ongoing { // Update the room kind to ongoing, // but keep the room kind if send failed if reports.iter().all(|r| !r.is_sent_success()) { this.kind = RoomKind::Ongoing; cx.notify(); } } }); // Insert the sent reports this.reports_by_id.insert(rumor_id, reports); cx.notify(); } Err(e) => { window.push_notification(e.to_string(), cx); } } }) .ok(); }), ); } /// Resend a failed message #[allow(dead_code)] fn resend_message(&mut self, id: &EventId, window: &mut Window, cx: &mut Context) { if let Some(reports) = self.reports_by_id.get(id).cloned() { let id_clone = id.to_owned(); let resend = self.room.read(cx).resend_message(reports, cx); cx.spawn_in(window, async move |this, cx| { let result = resend.await; this.update_in(cx, |this, window, cx| { match result { Ok(reports) => { this.reports_by_id.entry(id_clone).and_modify(|this| { *this = reports; }); cx.notify(); } Err(e) => { window.push_notification(Notification::error(e.to_string()), cx); } }; }) .ok(); }) .detach(); } } /// 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(); // Extend the messages list with the new events if self.messages.insert(m.into()) { 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: Vec, cx: &mut Context) { for event in events { let m = Message::user(event); // Bulk inserting messages, so no need to scroll to the latest message self.insert_message(m, false, cx); } } /// Insert a warning message into the chat panel #[allow(dead_code)] fn insert_warning(&mut self, content: impl Into, cx: &mut Context) { let m = Message::warning(content.into()); self.insert_message(m, true, cx); } /// Check if a message failed to send by its ID fn is_sent_failed(&self, id: &EventId) -> bool { self.reports_by_id .get(id) .is_some_and(|reports| reports.iter().all(|r| !r.is_sent_success())) } /// Check if a message was sent successfully by its ID fn is_sent_success(&self, id: &EventId) -> Option { self.reports_by_id .get(id) .map(|reports| reports.iter().all(|r| r.is_sent_success())) } /// Get the sent reports for a message by its ID fn sent_reports(&self, id: &EventId) -> Option<&Vec> { self.reports_by_id.get(id) } /// Get a message by its ID fn message(&self, id: &EventId) -> Option<&RenderedMessage> { self.messages.iter().find_map(|msg| { if let Message::User(rendered) = msg { if &rendered.id == id { return Some(rendered); } } None }) } fn profile(&self, public_key: &PublicKey, cx: &Context) -> Profile { let persons = PersonRegistry::global(cx); persons.read(cx).get_person(public_key, cx) } fn signer_kind(&self, cx: &App) -> SignerKind { self.options.read(cx).signer_kind } fn scroll_to(&self, id: EventId) { if let Some(ix) = self.messages.iter().position(|m| { if let Message::User(msg) = m { msg.id == id } else { false } }) { self.list_state.scroll_to_reveal_item(ix); } } fn copy_message(&self, id: &EventId, cx: &Context) { if let Some(message) = self.message(id) { cx.write_to_clipboard(ClipboardItem::new_string(message.content.to_string())); } } 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 remove_all_replies(&mut self, cx: &mut Context) { self.replies_to.update(cx, |this, cx| { this.clear(); cx.notify(); }); } fn upload(&mut self, window: &mut Window, cx: &mut Context) { let nostr = NostrRegistry::global(cx); let client = nostr.read(cx).client(); // Get the user's configured NIP96 server let nip96_server = AppSettings::get_media_server(cx); let path = cx.prompt_for_paths(PathPromptOptions { files: true, directories: false, multiple: false, prompt: None, }); cx.spawn_in(window, async move |this, cx| { let mut paths = path.await.ok()?.ok()??; let path = paths.pop()?; let upload = Tokio::spawn(cx, async move { let file = fs::read(path).await.ok()?; let url = nip96_upload(&client, &nip96_server, file).await.ok()?; Some(url) }); if let Ok(task) = upload { this.update(cx, |this, cx| { this.set_uploading(true, cx); }) .ok(); let result = Flatten::flatten(task.await.map_err(|e| e.into())); this.update_in(cx, |this, window, cx| { match result { Ok(Some(url)) => { this.add_attachment(url, cx); this.set_uploading(false, cx); } Ok(None) => { this.set_uploading(false, cx); } Err(e) => { window.push_notification(Notification::error(e.to_string()), cx); this.set_uploading(false, cx); } }; }) .ok(); } Some(()) }) .detach(); } 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 remove_all_attachments(&mut self, cx: &mut Context) { self.attachments.update(cx, |this, cx| { this.clear(); cx.notify(); }); } fn render_announcement(&self, ix: usize, cx: &Context) -> AnyElement { v_flex() .id(ix) .group("") .h_32() .w_full() .relative() .gap_3() .px_3() .py_2() .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_10() .text_color(cx.theme().elevated_surface_background), ) .child(SharedString::from( "This conversation is private. Only members can see each other's messages.", )) .into_any_element() } fn render_warning(&self, ix: usize, content: SharedString, cx: &Context) -> AnyElement { div() .id(ix) .relative() .w_full() .py_1() .px_3() .bg(cx.theme().warning_background) .child( h_flex() .gap_3() .text_sm() .text_color(cx.theme().warning_foreground) .child(Avatar::new("brand/system.png").size(rems(2.))) .child(content), ) .child( div() .absolute() .left_0() .top_0() .w(px(2.)) .h_full() .bg(cx.theme().warning_active), ) .into_any_element() } fn render_message( &mut self, ix: usize, window: &mut Window, cx: &mut Context, ) -> AnyElement { if let Some(message) = self.messages.get_index(ix) { match message { Message::User(rendered) => { let text = self .rendered_texts_by_id .entry(rendered.id) .or_insert_with(|| RenderedText::new(&rendered.content, cx)) .element(ix.into(), window, cx); self.render_text_message(ix, rendered, text, cx) } Message::Warning(content, _timestamp) => { self.render_warning(ix, SharedString::from(content), cx) } Message::System(_timestamp) => self.render_announcement(ix, cx), } } else { self.render_warning(ix, SharedString::from("Message not found"), cx) } } fn render_text_message( &self, ix: usize, message: &RenderedMessage, text: AnyElement, cx: &Context, ) -> AnyElement { let proxy = AppSettings::get_proxy_user_avatars(cx); let hide_avatar = AppSettings::get_hide_user_avatars(cx); let id = message.id; let author = self.profile(&message.author, cx); let public_key = author.public_key(); let replies = message.replies_to.as_slice(); let has_replies = !replies.is_empty(); // Check if message is sent failed let is_sent_failed = self.is_sent_failed(&id); // Check if message is sent successfully let is_sent_success = self.is_sent_success(&id); div() .id(ix) .group("") .relative() .w_full() .py_1() .px_3() .child( div() .flex() .gap_3() .when(!hide_avatar, |this| { this.child( div() .id(SharedString::from(format!("{ix}-avatar"))) .child(Avatar::new(author.avatar(proxy)).size(rems(2.))) .context_menu(move |this, _window, _cx| { let view = Box::new(OpenPublicKey(public_key)); let copy = Box::new(CopyPublicKey(public_key)); this.menu("View Profile", view) .menu("Copy Public Key", copy) }), ) }) .child( v_flex() .flex_1() .w_full() .flex_initial() .overflow_hidden() .child( h_flex() .gap_2() .text_sm() .text_color(cx.theme().text_placeholder) .child( div() .font_semibold() .text_color(cx.theme().text) .child(author.display_name()), ) .child(message.created_at.to_human_time()) .when_some(is_sent_success, |this, status| { this.when(status, |this| { this.child(self.render_message_sent(&id, cx)) }) }), ) .when(has_replies, |this| { this.children(self.render_message_replies(replies, cx)) }) .child(text) .when(is_sent_failed, |this| { this.child(self.render_message_reports(&id, cx)) }), ), ) .child(self.render_border(cx)) .child(self.render_actions(&id, 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_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_selected) .text_sm() .child( div() .text_color(cx.theme().text_accent) .child(author.display_name()), ) .child( div() .w_full() .text_ellipsis() .line_clamp(1) .child(SharedString::from(&message.content)), ) .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_message_sent(&self, id: &EventId, _cx: &Context) -> impl IntoElement { div() .id(SharedString::from(id.to_hex())) .child(SharedString::from("• Sent")) .when_some(self.sent_reports(id).cloned(), |this, reports| { this.on_click(move |_e, window, cx| { let reports = reports.clone(); window.open_modal(cx, move |this, _window, cx| { this.show_close(true) .title(SharedString::from("Sent Reports")) .child(v_flex().pb_4().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_message_reports(&self, id: &EventId, cx: &Context) -> impl IntoElement { h_flex() .id(SharedString::from(id.to_hex())) .gap_0p5() .text_color(cx.theme().danger_foreground) .text_xs() .italic() .child(Icon::new(IconName::Info).xsmall()) .child(SharedString::from( "Failed to send message. Click to see details.", )) .when_some(self.sent_reports(id).cloned(), |this, reports| { this.on_click(move |_e, window, cx| { let reports = reports.clone(); window.open_modal(cx, move |this, _window, cx| { this.show_close(true) .title(SharedString::from("Sent Reports")) .child(v_flex().gap_4().pb_4().w_full().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_person(&report.receiver, cx); let name = profile.display_name(); let avatar = profile.avatar(true); 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).size(rems(1.25))) .child(name.clone()), ), ) .when(report.relays_not_found, |this| { this.child( h_flex() .flex_wrap() .justify_center() .p_2() .h_20() .w_full() .text_sm() .rounded(cx.theme().radius) .bg(cx.theme().danger_background) .text_color(cx.theme().danger_foreground) .child( div() .flex_1() .w_full() .text_center() .child(SharedString::from("Messaging Relays not found")), ), ) }) .when(report.device_not_found, |this| { this.child( h_flex() .flex_wrap() .justify_center() .p_2() .h_20() .w_full() .text_sm() .rounded(cx.theme().radius) .bg(cx.theme().danger_background) .text_color(cx.theme().danger_foreground) .child( div() .flex_1() .w_full() .text_center() .child(SharedString::from("Encryption Key not found")), ), ) }) .when_some(report.error.clone(), |this, error| { this.child( h_flex() .flex_wrap() .justify_center() .p_2() .h_20() .w_full() .text_sm() .rounded(cx.theme().radius) .bg(cx.theme().danger_background) .text_color(cx.theme().danger_foreground) .child(div().flex_1().w_full().text_center().child(error)), ) }) .when_some(report.status.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() .py_1() .px_2() .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.to_string())), ) .child( div() .text_sm() .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() .py_1() .px_2() .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.to_string())), ) .child( div() .text_sm() .text_color(cx.theme().secondary_foreground) .line_height(relative(1.25)) .child(SharedString::from("Successfully")), ), ) } items }), ) }) } fn render_border(&self, cx: &Context) -> impl IntoElement { 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) } fn render_actions(&self, id: &EventId, 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) .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("seen-on") .icon(IconName::Ellipsis) .small() .ghost() .popup_menu({ let id = id.to_owned(); move |this, _, _| this.menu("Seen on", Box::new(SeenOn(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::ScaleDown), ) .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 } 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_person(&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(SharedString::from("Replying to:")) .child( div() .text_color(cx.theme().text_accent) .child(profile.display_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(SharedString::from(&text.content)), ) } 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 subject_button(&self, cx: &App) -> Button { let room = self.room.downgrade(); let subject = self .room .read(cx) .subject .as_ref() .map(|subject| subject.to_string()); Button::new("subject") .icon(IconName::Edit) .tooltip("Change the subject of the conversation") .on_click(move |_, window, cx| { let view = subject::init(subject.clone(), window, cx); let room = room.clone(); let weak_view = view.downgrade(); window.open_modal(cx, move |this, _window, _cx| { let room = room.clone(); let weak_view = weak_view.clone(); this.confirm() .title("Change the subject of the conversation") .child(view.clone()) .button_props(ModalButtonProps::default().ok_text("Change")) .on_ok(move |_, _window, cx| { if let Ok(subject) = weak_view.read_with(cx, |this, cx| this.new_subject(cx)) { room.update(cx, |this, cx| { this.set_subject(subject, cx); }) .ok(); } // true to close the modal true }) }); }) } fn reload_button(&self, _cx: &App) -> Button { let room = self.room.downgrade(); Button::new("reload") .icon(IconName::Refresh) .tooltip("Reload") .on_click(move |_ev, window, cx| { _ = room.update(cx, |this, cx| { this.emit_refresh(cx); window.push_notification("Reloaded", cx); }); }) } fn on_open_seen_on(&mut self, ev: &SeenOn, window: &mut Window, cx: &mut Context) { let id = ev.0; let nostr = NostrRegistry::global(cx); let client = nostr.read(cx).client(); let tracker = nostr.read(cx).tracker(); let task: Task, Error>> = cx.background_spawn(async move { let tracker = tracker.read().await; let mut relays: Vec = vec![]; let filter = Filter::new() .kind(Kind::ApplicationSpecificData) .event(id) .limit(1); if let Some(event) = client.database().query(filter).await?.first_owned() { if let Some(Ok(id)) = event.tags.identifier().map(EventId::parse) { if let Some(urls) = tracker.seen_on_relays.get(&id).cloned() { relays.extend(urls); } } } Ok(relays) }); cx.spawn_in(window, async move |_, cx| { if let Ok(urls) = task.await { cx.update(|window, cx| { window.open_modal(cx, move |this, _window, cx| { this.show_close(true) .title(SharedString::from("Seen on")) .child(v_flex().pb_4().gap_2().children({ let mut items = Vec::with_capacity(urls.len()); for url in urls.clone().into_iter() { items.push( h_flex() .h_8() .px_2() .bg(cx.theme().elevated_surface_background) .rounded(cx.theme().radius) .font_semibold() .text_xs() .child(SharedString::from(url.to_string())), ) } items })) }); }) .ok(); } }) .detach(); } fn on_set_encryption(&mut self, ev: &SetSigner, _: &mut Window, cx: &mut Context) { self.options.update(cx, move |this, cx| { this.signer_kind = ev.0; cx.notify(); }); } } 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 proxy = AppSettings::get_proxy_user_avatars(cx); let label = this.display_name(cx); let url = this.display_image(proxy, cx); h_flex() .gap_1p5() .child(Avatar::new(url).size(rems(1.25))) .child(label) .into_any() }) } fn toolbar_buttons(&self, _window: &Window, cx: &App) -> Vec