diff --git a/Cargo.lock b/Cargo.lock index 36164c7..b14c862 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1009,7 +1009,6 @@ dependencies = [ "chat", "common", "dock", - "emojis", "gpui", "gpui_tokio", "indexset", @@ -1848,15 +1847,6 @@ dependencies = [ "winreg", ] -[[package]] -name = "emojis" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99e1f1df1f181f2539bac8bf027d31ca5ffbf9e559e3f2d09413b9107b5c02f4" -dependencies = [ - "phf", -] - [[package]] name = "encoding_rs" version = "0.8.35" diff --git a/assets/icons/paper-plane-fill.svg b/assets/icons/paper-plane-fill.svg new file mode 100644 index 0000000..8231630 --- /dev/null +++ b/assets/icons/paper-plane-fill.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/chat_ui/Cargo.toml b/crates/chat_ui/Cargo.toml index 9f99fc0..43a3157 100644 --- a/crates/chat_ui/Cargo.toml +++ b/crates/chat_ui/Cargo.toml @@ -27,6 +27,5 @@ serde.workspace = true serde_json.workspace = true indexset = "0.12.3" -emojis = "0.6.4" once_cell = "1.19.0" regex = "1" diff --git a/crates/chat_ui/src/actions.rs b/crates/chat_ui/src/actions.rs index bea282e..ab28139 100644 --- a/crates/chat_ui/src/actions.rs +++ b/crates/chat_ui/src/actions.rs @@ -2,6 +2,13 @@ use gpui::Action; use nostr_sdk::prelude::*; use serde::Deserialize; +#[derive(Action, Clone, PartialEq, Eq, Deserialize)] +#[action(namespace = chat, no_json)] +pub enum Command { + Insert(&'static str), + ChangeSubject(&'static str), +} + #[derive(Action, Clone, PartialEq, Eq, Deserialize)] #[action(namespace = chat, no_json)] pub struct SeenOn(pub EventId); diff --git a/crates/chat_ui/src/emoji.rs b/crates/chat_ui/src/emoji.rs deleted file mode 100644 index c60aeeb..0000000 --- a/crates/chat_ui/src/emoji.rs +++ /dev/null @@ -1,139 +0,0 @@ -use std::sync::OnceLock; - -use gpui::prelude::FluentBuilder; -use gpui::{ - div, px, App, AppContext, Corner, Element, InteractiveElement, IntoElement, ParentElement, - RenderOnce, SharedString, StatefulInteractiveElement, Styled, WeakEntity, Window, -}; -use theme::ActiveTheme; -use ui::button::{Button, ButtonVariants}; -use ui::input::InputState; -use ui::popover::{Popover, PopoverContent}; -use ui::{Icon, Sizable, Size}; - -static EMOJIS: OnceLock> = OnceLock::new(); - -fn get_emojis() -> &'static Vec { - EMOJIS.get_or_init(|| { - let mut emojis: Vec = vec![]; - - emojis.extend( - emojis::Group::SmileysAndEmotion - .emojis() - .map(|e| SharedString::from(e.as_str())) - .collect::>(), - ); - - emojis - }) -} - -#[derive(IntoElement)] -pub struct EmojiPicker { - target: Option>, - icon: Option, - anchor: Option, - size: Size, -} - -impl EmojiPicker { - pub fn new() -> Self { - Self { - size: Size::default(), - target: None, - anchor: None, - icon: None, - } - } - - pub fn target(mut self, target: WeakEntity) -> Self { - self.target = Some(target); - self - } - - pub fn icon(mut self, icon: impl Into) -> Self { - self.icon = Some(icon.into()); - self - } - - #[allow(dead_code)] - pub fn anchor(mut self, corner: Corner) -> Self { - self.anchor = Some(corner); - self - } -} - -impl Sizable for EmojiPicker { - fn with_size(mut self, size: impl Into) -> Self { - self.size = size.into(); - self - } -} - -impl RenderOnce for EmojiPicker { - fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement { - Popover::new("emojis") - .map(|this| { - if let Some(corner) = self.anchor { - this.anchor(corner) - } else { - this.anchor(gpui::Corner::BottomLeft) - } - }) - .trigger( - Button::new("emojis-trigger") - .when_some(self.icon, |this, icon| this.icon(icon)) - .ghost() - .with_size(self.size), - ) - .content(move |window, cx| { - let input = self.target.clone(); - - cx.new(|cx| { - PopoverContent::new(window, cx, move |_window, cx| { - div() - .flex() - .flex_wrap() - .items_center() - .gap_2() - .children(get_emojis().iter().map(|e| { - div() - .id(e.clone()) - .flex_auto() - .size_10() - .flex() - .items_center() - .justify_center() - .rounded(cx.theme().radius) - .child(e.clone()) - .hover(|this| this.bg(cx.theme().ghost_element_hover)) - .on_click({ - let item = e.clone(); - let input = input.clone(); - - move |_, window, cx| { - if let Some(input) = input.as_ref() { - _ = input.update(cx, |this, cx| { - let value = this.value(); - let new_text = if value.is_empty() { - format!("{item}") - } else if value.ends_with(" ") { - format!("{value}{item}") - } else { - format!("{value} {item}") - }; - this.set_value(new_text, window, cx); - }); - } - } - }) - })) - .into_any() - }) - .scrollable() - .max_h(px(300.)) - .max_w(px(300.)) - }) - }) - } -} diff --git a/crates/chat_ui/src/lib.rs b/crates/chat_ui/src/lib.rs index a29ec1c..4cb3ecc 100644 --- a/crates/chat_ui/src/lib.rs +++ b/crates/chat_ui/src/lib.rs @@ -34,11 +34,9 @@ use ui::{ WindowExtension, }; -use crate::emoji::EmojiPicker; use crate::text::RenderedText; mod actions; -mod emoji; mod text; pub fn init(room: WeakEntity, window: &mut Window, cx: &mut App) -> Entity { @@ -87,28 +85,40 @@ pub struct ChatPanel { impl ChatPanel { pub fn new(room: WeakEntity, window: &mut Window, cx: &mut Context) -> Self { + // 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("Message...") + .placeholder(format!("Message {}", name)) .auto_grow(1, 20) .prevent_new_line_on_enter() .clean_on_escape() }); + // Define attachments and replies_to entities let attachments = cx.new(|_| vec![]); let replies_to = cx.new(|_| HashSet::new()); + // Define list of messages let messages = BTreeSet::from([Message::system()]); let list_state = ListState::new(messages.len(), ListAlignment::Bottom, px(1024.)); - let id: SharedString = room - .read_with(cx, |this, _cx| this.id.to_string().into()) - .unwrap_or("Unknown".into()); - let mut subscriptions = smallvec![]; let mut tasks = smallvec![]; - if let Ok(connect) = room.read_with(cx, |this, cx| this.connect(cx)) { + if let Some(room) = room.upgrade() { + let connect = room.read(cx).connect(cx); + let get_messages = room.read(cx).get_messages(cx); + tasks.push( // Get messaging relays and encryption keys announcement for each member cx.background_spawn(async move { @@ -117,9 +127,7 @@ impl ChatPanel { } }), ); - }; - if let Ok(get_messages) = room.read_with(cx, |this, cx| this.get_messages(cx)) { tasks.push( // Load all messages belonging to this room cx.spawn_in(window, async move |this, cx| { @@ -138,9 +146,7 @@ impl ChatPanel { .ok(); }), ); - } - if let Some(room) = room.upgrade() { subscriptions.push( // Subscribe to room events cx.subscribe_in(&room, window, move |this, _room, event, window, cx| { @@ -158,15 +164,11 @@ impl ChatPanel { 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); - }; - }, - ), + cx.subscribe_in(&input, window, move |this, _input, event, window, cx| { + if let InputEvent::PressEnter { .. } = event { + this.send_input_message(window, cx); + }; + }), ); Self { @@ -209,7 +211,7 @@ impl ChatPanel { } /// Get user input content and merged all attachments - fn input_content(&self, cx: &Context) -> String { + fn get_input_value(&self, cx: &Context) -> String { // Get input's value let mut content = self.input.read(cx).value().trim().to_string(); @@ -233,10 +235,9 @@ impl ChatPanel { content } - /// Send a message to all members of the chat - fn send_message(&mut self, window: &mut Window, cx: &mut Context) { + fn send_input_message(&mut self, window: &mut Window, cx: &mut Context) { // Get the message which includes all attachments - let content = self.input_content(cx); + let content = self.get_input_value(cx); // Return if message is empty if content.trim().is_empty() { @@ -244,13 +245,18 @@ impl ChatPanel { return; } + self.send_message(&content, window, cx); + } + + /// Send a message to all members of the chat + fn send_message(&mut self, value: &str, window: &mut Window, cx: &mut Context) { // Get replies_to if it's present let replies: Vec = self.replies_to.read(cx).iter().copied().collect(); // Get a task to create temporary message for optimistic update let Ok(get_rumor) = self .room - .read_with(cx, |this, cx| this.create_message(&content, replies, cx)) + .read_with(cx, |this, cx| this.create_message(value, replies, cx)) else { return; }; @@ -506,15 +512,15 @@ impl ChatPanel { } fn render_announcement(&self, ix: usize, cx: &Context) -> AnyElement { + const MSG: &str = + "This conversation is private. Only members can see each other's messages."; + v_flex() .id(ix) - .group("") - .h_32() + .h_40() .w_full() - .relative() .gap_3() - .px_3() - .py_2() + .p_3() .items_center() .justify_center() .text_center() @@ -524,12 +530,10 @@ impl ChatPanel { .child( svg() .path("brand/coop.svg") - .size_10() - .text_color(cx.theme().elevated_surface_background), + .size_12() + .text_color(cx.theme().ghost_element_active), ) - .child(SharedString::from( - "This conversation is private. Only members can see each other's messages.", - )) + .child(SharedString::from(MSG)) .into_any_element() } @@ -1116,6 +1120,25 @@ impl ChatPanel { items } + + fn on_command(&mut self, command: &Command, window: &mut Window, cx: &mut Context) { + match command { + Command::Insert(content) => { + self.send_message(content, 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); + } + } + } + } } impl Panel for ChatPanel { @@ -1150,6 +1173,7 @@ impl Focusable for ChatPanel { impl Render for ChatPanel { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { v_flex() + .on_action(cx.listener(Self::on_command)) .image_cache(self.image_cache.clone()) .size_full() .child( @@ -1163,48 +1187,68 @@ impl Render for ChatPanel { .flex_1(), ) .child( - div() + v_flex() .flex_shrink_0() + .p_2() .w_full() - .relative() - .px_3() - .py_2() + .gap_1p5() + .children(self.render_attachment_list(window, cx)) + .children(self.render_reply_list(window, cx)) .child( - v_flex() - .gap_1p5() - .children(self.render_attachment_list(window, cx)) - .children(self.render_reply_list(window, cx)) + h_flex() + .items_end() .child( - div() - .w_full() - .flex() - .items_end() - .gap_2p5() + Button::new("upload") + .icon(IconName::Plus) + .tooltip("Upload media") + .loading(self.uploading) + .disabled(self.uploading) + .ghost() + .large() + .on_click(cx.listener(move |this, _ev, window, cx| { + this.upload(window, cx); + })), + ) + .child( + TextInput::new(&self.input) + .appearance(false) + .flex_1() + .text_sm(), + ) + .child( + h_flex() + .pl_1() + .gap_1() .child( - h_flex() - .gap_1() - .text_color(cx.theme().text_muted) - .child( - Button::new("upload") - .icon(IconName::Upload) - .loading(self.uploading) - .disabled(self.uploading) - .ghost() - .large() - .on_click(cx.listener( - move |this, _, window, cx| { - this.upload(window, cx); - }, - )), - ) - .child( - EmojiPicker::new() - .target(self.input.downgrade()) - .icon(IconName::Emoji) - .large(), + Button::new("emoji") + .icon(IconName::Emoji) + .ghost() + .large() + .popup_menu_with_anchor( + gpui::Corner::BottomLeft, + move |this, _window, _cx| { + this.axis(gpui::Axis::Horizontal) + .menu("👍", Box::new(Command::Insert("👍"))) + .menu("👎", Box::new(Command::Insert("👎"))) + .menu("😄", Box::new(Command::Insert("😄"))) + .menu("🎉", Box::new(Command::Insert("🎉"))) + .menu("😕", Box::new(Command::Insert("😕"))) + .menu("❤️", Box::new(Command::Insert("❤️"))) + .menu("🚀", Box::new(Command::Insert("🚀"))) + .menu("👀", Box::new(Command::Insert("👀"))) + }, ), ) - .child(TextInput::new(&self.input)), + .child( + Button::new("send") + .icon(IconName::PaperPlaneFill) + .disabled(self.uploading) + .ghost() + .large() + .on_click(cx.listener(move |this, _ev, window, cx| { + this.send_input_message(window, cx); + })), + ), ), ), ) diff --git a/crates/ui/src/icon.rs b/crates/ui/src/icon.rs index 01eb604..1dca603 100644 --- a/crates/ui/src/icon.rs +++ b/crates/ui/src/icon.rs @@ -51,6 +51,7 @@ pub enum IconName { PanelRightOpen, PanelBottom, PanelBottomOpen, + PaperPlaneFill, Warning, WindowClose, WindowMaximize, @@ -106,6 +107,7 @@ impl IconName { Self::PanelRightOpen => "icons/panel-right-open.svg", Self::PanelBottom => "icons/panel-bottom.svg", Self::PanelBottomOpen => "icons/panel-bottom-open.svg", + Self::PaperPlaneFill => "icons/paper-plane-fill.svg", Self::Warning => "icons/warning.svg", Self::WindowClose => "icons/window-close.svg", Self::WindowMaximize => "icons/window-maximize.svg", diff --git a/crates/ui/src/menu/popup_menu.rs b/crates/ui/src/menu/popup_menu.rs index b2987a7..2624f6e 100644 --- a/crates/ui/src/menu/popup_menu.rs +++ b/crates/ui/src/menu/popup_menu.rs @@ -2,11 +2,11 @@ use std::rc::Rc; use gpui::prelude::FluentBuilder; use gpui::{ - anchored, canvas, div, px, rems, Action, AnyElement, App, AppContext, Bounds, Context, Corner, - DismissEvent, Edges, Entity, EventEmitter, FocusHandle, Focusable, Half, InteractiveElement, - IntoElement, KeyBinding, MouseDownEvent, OwnedMenuItem, ParentElement, Pixels, Render, - ScrollHandle, SharedString, StatefulInteractiveElement, Styled, Subscription, WeakEntity, - Window, + anchored, canvas, div, px, rems, Action, AnyElement, App, AppContext, Axis, Bounds, Context, + Corner, DismissEvent, Edges, Entity, EventEmitter, FocusHandle, Focusable, Half, + InteractiveElement, IntoElement, KeyBinding, MouseDownEvent, OwnedMenuItem, ParentElement, + Pixels, Render, ScrollHandle, SharedString, StatefulInteractiveElement, Styled, Subscription, + WeakEntity, Window, }; use theme::ActiveTheme; @@ -119,6 +119,8 @@ pub struct PopupMenu { pub(crate) menu_items: Vec, /// The focus handle of Entity to handle actions. pub(crate) action_context: Option, + + axis_items: Axis, has_icon: bool, selected_index: Option, min_width: Option, @@ -126,13 +128,14 @@ pub struct PopupMenu { max_height: Option, bounds: Bounds, size: Size, - - /// The parent menu of this menu, if this is a submenu - parent_menu: Option>, scrollable: bool, external_link_icon: bool, scroll_handle: ScrollHandle, scroll_state: ScrollbarState, + + /// The parent menu of this menu, if this is a submenu + parent_menu: Option>, + // This will update on render submenu_anchor: (Corner, Pixels), @@ -144,6 +147,7 @@ impl PopupMenu { Self { focus_handle: cx.focus_handle(), action_context: None, + axis_items: Axis::Vertical, parent_menu: None, menu_items: Vec::new(), selected_index: None, @@ -180,6 +184,12 @@ impl PopupMenu { self } + /// Set the axis of the popup menu, default is vertical + pub fn axis(mut self, axis: Axis) -> Self { + self.axis_items = axis; + self + } + /// Set min width of the popup menu, default is 120px pub fn min_w(mut self, width: impl Into) -> Self { self.min_width = Some(width.into()); @@ -1131,8 +1141,15 @@ impl Render for PopupMenu { .text_color(cx.theme().text) .relative() .child( - v_flex() + div() .id("items") + .map(|this| { + if self.axis_items == Axis::Vertical { + this.flex().flex_col() + } else { + this.flex().flex_row().items_center() + } + }) .p_1() .gap_y_0p5() .min_w(rems(8.))