From 107fedeafdba545418aec1903c46dd8d20dea897 Mon Sep 17 00:00:00 2001
From: reya <123083837+reyamir@users.noreply.github.com>
Date: Sat, 26 Apr 2025 08:39:52 +0700
Subject: [PATCH] feat: Emoji Picker (#18)
* wip: emoji picker
* update
---
Cargo.lock | 10 +++
Cargo.toml | 1 +
assets/icons/emoji-fill.svg | 3 +
crates/chats/src/room.rs | 28 +++++++
crates/coop/src/views/chat.rs | 34 ++++++---
crates/ui/Cargo.toml | 1 +
crates/ui/src/button.rs | 2 +-
crates/ui/src/divider.rs | 3 +-
crates/ui/src/emoji_picker.rs | 136 ++++++++++++++++++++++++++++++++++
crates/ui/src/icon.rs | 2 +
crates/ui/src/lib.rs | 1 +
crates/ui/src/popover.rs | 30 +++++++-
crates/ui/src/styled.rs | 2 +-
13 files changed, 238 insertions(+), 15 deletions(-)
create mode 100644 assets/icons/emoji-fill.svg
create mode 100644 crates/ui/src/emoji_picker.rs
diff --git a/Cargo.lock b/Cargo.lock
index 35d9eae..5aef228 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1684,6 +1684,15 @@ 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"
@@ -6449,6 +6458,7 @@ dependencies = [
"anyhow",
"chrono",
"common",
+ "emojis",
"gpui",
"image",
"itertools 0.13.0",
diff --git a/Cargo.toml b/Cargo.toml
index dee4a6e..d826110 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -30,6 +30,7 @@ nostr-sdk = { git = "https://github.com/rust-nostr/nostr", features = [
] }
# Others
+emojis = "0.6.4"
smol = "2"
oneshot = "0.1.10"
serde = { version = "1.0", features = ["derive"] }
diff --git a/assets/icons/emoji-fill.svg b/assets/icons/emoji-fill.svg
new file mode 100644
index 0000000..974ccf4
--- /dev/null
+++ b/assets/icons/emoji-fill.svg
@@ -0,0 +1,3 @@
+
diff --git a/crates/chats/src/room.rs b/crates/chats/src/room.rs
index e7bb896..1ba41de 100644
--- a/crates/chats/src/room.rs
+++ b/crates/chats/src/room.rs
@@ -40,6 +40,8 @@ pub struct Room {
pub created_at: Timestamp,
/// Subject of the room
pub subject: Option,
+ /// Picture of the room
+ pub picture: Option,
/// All members of the room
pub members: Arc>,
/// Kind
@@ -82,10 +84,18 @@ impl Room {
None
};
+ // Get the picture from the event's tags
+ let picture = if let Some(tag) = event.tags.find(TagKind::custom("picture")) {
+ tag.content().map(|s| s.to_owned().into())
+ } else {
+ None
+ };
+
Self {
id,
created_at,
subject,
+ picture,
members,
kind: RoomKind::Unknown,
}
@@ -303,6 +313,17 @@ impl Room {
cx.notify();
}
+ /// Updates the picture of the room
+ ///
+ /// # Arguments
+ ///
+ /// * `picture` - The new subject to set
+ /// * `cx` - The context to notify about the update
+ pub fn picture(&mut self, picture: String, cx: &mut Context) {
+ self.picture = Some(picture.into());
+ cx.notify();
+ }
+
/// Fetches metadata for all members in the room
///
/// # Arguments
@@ -380,6 +401,7 @@ impl Room {
let public_key = profile.public_key();
let subject = self.subject.clone();
+ let picture = self.picture.clone();
let pubkeys = self.members.clone();
let (tx, rx) = smol::channel::bounded::(pubkeys.len());
@@ -398,12 +420,18 @@ impl Room {
})
.collect();
+ // Add subject tag if it's present
if let Some(subject) = subject {
tags.push(Tag::from_standardized(TagStandard::Subject(
subject.to_string(),
)));
}
+ // Add picture tag if it's present
+ if let Some(picture) = picture {
+ tags.push(Tag::custom(TagKind::custom("picture"), vec![picture]));
+ }
+
for pubkey in pubkeys.iter() {
match client
.send_private_msg(*pubkey, &content, tags.clone())
diff --git a/crates/coop/src/views/chat.rs b/crates/coop/src/views/chat.rs
index 1981937..37b416e 100644
--- a/crates/coop/src/views/chat.rs
+++ b/crates/coop/src/views/chat.rs
@@ -22,6 +22,7 @@ use std::{collections::HashMap, sync::Arc};
use ui::{
button::{Button, ButtonVariants},
dock_area::panel::{Panel, PanelEvent},
+ emoji_picker::EmojiPicker,
input::{InputEvent, TextInput},
notification::Notification,
popup_menu::PopupMenu,
@@ -594,16 +595,31 @@ impl Render for Chat {
.w_full()
.flex()
.items_center()
- .gap_2()
+ .gap_2p5()
.child(
- Button::new("upload")
- .icon(Icon::new(IconName::Upload))
- .ghost()
- .on_click(cx.listener(move |this, _, window, cx| {
- this.upload_media(window, cx);
- }))
- .disabled(self.is_uploading)
- .loading(self.is_uploading),
+ div()
+ .flex()
+ .items_center()
+ .gap_1()
+ .text_color(
+ cx.theme().base.step(cx, ColorScaleStep::ELEVEN),
+ )
+ .child(
+ Button::new("upload")
+ .icon(Icon::new(IconName::Upload))
+ .ghost()
+ .disabled(self.is_uploading)
+ .loading(self.is_uploading)
+ .on_click(cx.listener(
+ move |this, _, window, cx| {
+ this.upload_media(window, cx);
+ },
+ )),
+ )
+ .child(
+ EmojiPicker::new(self.input.downgrade())
+ .icon(IconName::EmojiFill),
+ ),
)
.child(self.input.clone()),
),
diff --git a/crates/ui/Cargo.toml b/crates/ui/Cargo.toml
index 0e94842..7429d5b 100644
--- a/crates/ui/Cargo.toml
+++ b/crates/ui/Cargo.toml
@@ -24,3 +24,4 @@ uuid = "1.10"
once_cell = "1.19.0"
image = "0.25.1"
linkify = "0.10.0"
+emojis.workspace = true
diff --git a/crates/ui/src/button.rs b/crates/ui/src/button.rs
index ee0115c..4763a1f 100644
--- a/crates/ui/src/button.rs
+++ b/crates/ui/src/button.rs
@@ -348,7 +348,7 @@ impl RenderOnce for Button {
Size::Size(px) => this.size(px),
Size::XSmall => this.size_5(),
Size::Small => this.size_6(),
- _ => this.size_8(),
+ _ => this.size_9(),
}
} else {
// Normal Button
diff --git a/crates/ui/src/divider.rs b/crates/ui/src/divider.rs
index fde513c..f096144 100644
--- a/crates/ui/src/divider.rs
+++ b/crates/ui/src/divider.rs
@@ -1,9 +1,10 @@
-use crate::theme::{scale::ColorScaleStep, ActiveTheme};
use gpui::{
div, prelude::FluentBuilder as _, px, Axis, Div, Hsla, IntoElement, ParentElement, RenderOnce,
SharedString, Styled,
};
+use crate::theme::{scale::ColorScaleStep, ActiveTheme};
+
/// A divider that can be either vertical or horizontal.
#[derive(IntoElement)]
pub struct Divider {
diff --git a/crates/ui/src/emoji_picker.rs b/crates/ui/src/emoji_picker.rs
new file mode 100644
index 0000000..8c874fe
--- /dev/null
+++ b/crates/ui/src/emoji_picker.rs
@@ -0,0 +1,136 @@
+use std::rc::Rc;
+
+use gpui::{
+ div, impl_internal_actions, prelude::FluentBuilder, px, App, AppContext, Corner, Element,
+ InteractiveElement, IntoElement, ParentElement, RenderOnce, SharedString,
+ StatefulInteractiveElement, Styled, WeakEntity, Window,
+};
+use serde::Deserialize;
+
+use crate::{
+ button::{Button, ButtonVariants},
+ input::TextInput,
+ popover::{Popover, PopoverContent},
+ theme::{scale::ColorScaleStep, ActiveTheme},
+ Icon,
+};
+
+#[derive(PartialEq, Clone, Debug, Deserialize)]
+pub struct EmitEmoji(pub SharedString);
+
+impl_internal_actions!(emoji, [EmitEmoji]);
+
+#[derive(IntoElement)]
+pub struct EmojiPicker {
+ icon: Option,
+ anchor: Option,
+ input: WeakEntity,
+ emojis: Rc>,
+}
+
+impl EmojiPicker {
+ pub fn new(input: WeakEntity) -> Self {
+ let mut emojis: Vec = vec![];
+
+ emojis.extend(
+ emojis::Group::SmileysAndEmotion
+ .emojis()
+ .map(|e| SharedString::from(e.as_str()))
+ .collect::>(),
+ );
+
+ emojis.extend(
+ emojis::Group::Symbols
+ .emojis()
+ .map(|e| SharedString::from(e.as_str()))
+ .collect::>(),
+ );
+
+ Self {
+ input,
+ emojis: emojis.into(),
+ anchor: None,
+ icon: None,
+ }
+ }
+
+ pub fn icon(mut self, icon: impl Into) -> Self {
+ self.icon = Some(icon.into());
+ self
+ }
+
+ pub fn anchor(mut self, corner: Corner) -> Self {
+ self.anchor = Some(corner);
+ self
+ }
+}
+
+impl RenderOnce for EmojiPicker {
+ fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
+ Popover::new("emoji-picker")
+ .map(|this| {
+ if let Some(corner) = self.anchor {
+ this.anchor(corner)
+ } else {
+ this.anchor(gpui::Corner::BottomLeft)
+ }
+ })
+ .trigger(
+ Button::new("emoji-trigger")
+ .when_some(self.icon, |this, icon| this.icon(icon))
+ .ghost(),
+ )
+ .content(move |window, cx| {
+ let emojis = self.emojis.clone();
+ let input = self.input.clone();
+
+ cx.new(|cx| {
+ PopoverContent::new(window, cx, move |_window, cx| {
+ div()
+ .flex()
+ .flex_wrap()
+ .items_center()
+ .gap_2()
+ .children(emojis.iter().map(|e| {
+ div()
+ .id(e.clone())
+ .flex_auto()
+ .size_10()
+ .flex()
+ .items_center()
+ .justify_center()
+ .rounded(px(cx.theme().radius))
+ .child(e.clone())
+ .hover(|this| {
+ this.bg(cx.theme().base.step(cx, ColorScaleStep::THREE))
+ })
+ .on_click({
+ let item = e.clone();
+ let input = input.upgrade();
+
+ move |_, window, cx| {
+ if let Some(input) = input.as_ref() {
+ input.update(cx, |this, cx| {
+ let current = this.text();
+ let new_text = if current.is_empty() {
+ format!("{}", item)
+ } else if current.ends_with(" ") {
+ format!("{}{}", current, item)
+ } else {
+ format!("{} {}", current, item)
+ };
+ this.set_text(new_text, window, cx);
+ });
+ }
+ }
+ })
+ }))
+ .into_any()
+ })
+ .scrollable()
+ .max_h(px(300.))
+ .max_w(px(300.))
+ })
+ })
+ }
+}
diff --git a/crates/ui/src/icon.rs b/crates/ui/src/icon.rs
index ad78091..3f631be 100644
--- a/crates/ui/src/icon.rs
+++ b/crates/ui/src/icon.rs
@@ -33,6 +33,7 @@ pub enum IconName {
Ellipsis,
Eye,
EyeOff,
+ EmojiFill,
Folder,
FolderFill,
Inbox,
@@ -96,6 +97,7 @@ impl IconName {
Self::EditFill => "icons/edit-fill.svg",
Self::Ellipsis => "icons/ellipsis.svg",
Self::Eye => "icons/eye.svg",
+ Self::EmojiFill => "icons/emoji-fill.svg",
Self::EyeOff => "icons/eye-off.svg",
Self::Folder => "icons/folder.svg",
Self::FolderFill => "icons/folder-fill.svg",
diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs
index e6de94f..ef4005a 100644
--- a/crates/ui/src/lib.rs
+++ b/crates/ui/src/lib.rs
@@ -7,6 +7,7 @@ pub mod context_menu;
pub mod divider;
pub mod dock_area;
pub mod dropdown;
+pub mod emoji_picker;
pub mod history;
pub mod indicator;
pub mod input;
diff --git a/crates/ui/src/popover.rs b/crates/ui/src/popover.rs
index 273a2a1..2be97b0 100644
--- a/crates/ui/src/popover.rs
+++ b/crates/ui/src/popover.rs
@@ -1,12 +1,14 @@
-use crate::{Selectable, StyledExt as _};
+use std::{cell::RefCell, rc::Rc};
+
use gpui::{
actions, anchored, deferred, div, prelude::FluentBuilder as _, px, AnyElement, App, Bounds,
Context, Corner, DismissEvent, DispatchPhase, Element, ElementId, Entity, EventEmitter,
FocusHandle, Focusable, GlobalElementId, Hitbox, InteractiveElement as _, IntoElement,
KeyBinding, LayoutId, ManagedView, MouseButton, MouseDownEvent, ParentElement, Pixels, Point,
- Render, Style, StyleRefinement, Styled, Window,
+ Render, ScrollHandle, StatefulInteractiveElement, Style, StyleRefinement, Styled, Window,
};
-use std::{cell::RefCell, rc::Rc};
+
+use crate::{Selectable, StyledExt as _};
const CONTEXT: &str = "Popover";
@@ -20,7 +22,10 @@ type PopoverChild = Rc) -> AnyElement>;
pub struct PopoverContent {
focus_handle: FocusHandle,
+ scroll_handle: ScrollHandle,
max_width: Option,
+ max_height: Option,
+ scrollable: bool,
child: PopoverChild,
}
@@ -30,11 +35,15 @@ impl PopoverContent {
B: Fn(&mut Window, &mut Context) -> AnyElement + 'static,
{
let focus_handle = cx.focus_handle();
+ let scroll_handle = ScrollHandle::default();
Self {
focus_handle,
+ scroll_handle,
child: Rc::new(content),
max_width: None,
+ max_height: None,
+ scrollable: false,
}
}
@@ -42,6 +51,16 @@ impl PopoverContent {
self.max_width = Some(max_width);
self
}
+
+ pub fn max_h(mut self, max_height: Pixels) -> Self {
+ self.max_height = Some(max_height);
+ self
+ }
+
+ pub fn scrollable(mut self) -> Self {
+ self.scrollable = true;
+ self
+ }
}
impl EventEmitter for PopoverContent {}
@@ -55,11 +74,16 @@ impl Focusable for PopoverContent {
impl Render for PopoverContent {
fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement {
div()
+ .id("popup-content")
.track_focus(&self.focus_handle)
.key_context(CONTEXT)
.on_action(cx.listener(|_, _: &Escape, _, cx| cx.emit(DismissEvent)))
.p_2()
+ .when(self.scrollable, |this| {
+ this.overflow_y_scroll().track_scroll(&self.scroll_handle)
+ })
.when_some(self.max_width, |this, v| this.max_w(v))
+ .when_some(self.max_height, |this, v| this.max_h(v))
.child(self.child.clone()(window, cx))
}
}
diff --git a/crates/ui/src/styled.rs b/crates/ui/src/styled.rs
index 2e40986..a97e8de 100644
--- a/crates/ui/src/styled.rs
+++ b/crates/ui/src/styled.rs
@@ -64,7 +64,7 @@ pub trait StyledExt: Styled + Sized {
/// Set as Popover style
fn popover_style(self, cx: &mut App) -> Self {
- self.bg(cx.theme().background)
+ self.bg(cx.theme().base.step(cx, ColorScaleStep::TWO))
.border_1()
.border_color(cx.theme().base.step(cx, ColorScaleStep::SIX))
.shadow_lg()