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.))