feat: Emoji Picker (#18)

* wip: emoji picker

* update
This commit is contained in:
reya
2025-04-26 08:39:52 +07:00
committed by GitHub
parent 17251be3fd
commit 107fedeafd
13 changed files with 238 additions and 15 deletions

10
Cargo.lock generated
View File

@@ -1684,6 +1684,15 @@ dependencies = [
"winreg", "winreg",
] ]
[[package]]
name = "emojis"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "99e1f1df1f181f2539bac8bf027d31ca5ffbf9e559e3f2d09413b9107b5c02f4"
dependencies = [
"phf",
]
[[package]] [[package]]
name = "encoding_rs" name = "encoding_rs"
version = "0.8.35" version = "0.8.35"
@@ -6449,6 +6458,7 @@ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
"common", "common",
"emojis",
"gpui", "gpui",
"image", "image",
"itertools 0.13.0", "itertools 0.13.0",

View File

@@ -30,6 +30,7 @@ nostr-sdk = { git = "https://github.com/rust-nostr/nostr", features = [
] } ] }
# Others # Others
emojis = "0.6.4"
smol = "2" smol = "2"
oneshot = "0.1.10" oneshot = "0.1.10"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path fill="currentColor" fill-rule="evenodd" d="M2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Zm7.75-5a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5A.75.75 0 0 1 9.75 7Zm4.5 0a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5a.75.75 0 0 1 .75-.75Zm-6.143 7.864a.75.75 0 0 1 1.029-.257c1.04.624 1.97.905 2.864.905.894 0 1.824-.281 2.864-.905a.75.75 0 1 1 .772 1.286c-1.21.726-2.405 1.12-3.636 1.12-1.23 0-2.426-.394-3.636-1.12a.75.75 0 0 1-.257-1.029Z" clip-rule="evenodd"/>
</svg>

After

Width:  |  Height:  |  Size: 604 B

View File

@@ -40,6 +40,8 @@ pub struct Room {
pub created_at: Timestamp, pub created_at: Timestamp,
/// Subject of the room /// Subject of the room
pub subject: Option<SharedString>, pub subject: Option<SharedString>,
/// Picture of the room
pub picture: Option<SharedString>,
/// All members of the room /// All members of the room
pub members: Arc<Vec<PublicKey>>, pub members: Arc<Vec<PublicKey>>,
/// Kind /// Kind
@@ -82,10 +84,18 @@ impl Room {
None 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 { Self {
id, id,
created_at, created_at,
subject, subject,
picture,
members, members,
kind: RoomKind::Unknown, kind: RoomKind::Unknown,
} }
@@ -303,6 +313,17 @@ impl Room {
cx.notify(); 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>) {
self.picture = Some(picture.into());
cx.notify();
}
/// Fetches metadata for all members in the room /// Fetches metadata for all members in the room
/// ///
/// # Arguments /// # Arguments
@@ -380,6 +401,7 @@ impl Room {
let public_key = profile.public_key(); let public_key = profile.public_key();
let subject = self.subject.clone(); let subject = self.subject.clone();
let picture = self.picture.clone();
let pubkeys = self.members.clone(); let pubkeys = self.members.clone();
let (tx, rx) = smol::channel::bounded::<SendStatus>(pubkeys.len()); let (tx, rx) = smol::channel::bounded::<SendStatus>(pubkeys.len());
@@ -398,12 +420,18 @@ impl Room {
}) })
.collect(); .collect();
// Add subject tag if it's present
if let Some(subject) = subject { if let Some(subject) = subject {
tags.push(Tag::from_standardized(TagStandard::Subject( tags.push(Tag::from_standardized(TagStandard::Subject(
subject.to_string(), 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() { for pubkey in pubkeys.iter() {
match client match client
.send_private_msg(*pubkey, &content, tags.clone()) .send_private_msg(*pubkey, &content, tags.clone())

View File

@@ -22,6 +22,7 @@ use std::{collections::HashMap, sync::Arc};
use ui::{ use ui::{
button::{Button, ButtonVariants}, button::{Button, ButtonVariants},
dock_area::panel::{Panel, PanelEvent}, dock_area::panel::{Panel, PanelEvent},
emoji_picker::EmojiPicker,
input::{InputEvent, TextInput}, input::{InputEvent, TextInput},
notification::Notification, notification::Notification,
popup_menu::PopupMenu, popup_menu::PopupMenu,
@@ -594,16 +595,31 @@ impl Render for Chat {
.w_full() .w_full()
.flex() .flex()
.items_center() .items_center()
.gap_2() .gap_2p5()
.child( .child(
Button::new("upload") div()
.icon(Icon::new(IconName::Upload)) .flex()
.ghost() .items_center()
.on_click(cx.listener(move |this, _, window, cx| { .gap_1()
this.upload_media(window, cx); .text_color(
})) cx.theme().base.step(cx, ColorScaleStep::ELEVEN),
.disabled(self.is_uploading) )
.loading(self.is_uploading), .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()), .child(self.input.clone()),
), ),

View File

@@ -24,3 +24,4 @@ uuid = "1.10"
once_cell = "1.19.0" once_cell = "1.19.0"
image = "0.25.1" image = "0.25.1"
linkify = "0.10.0" linkify = "0.10.0"
emojis.workspace = true

View File

@@ -348,7 +348,7 @@ impl RenderOnce for Button {
Size::Size(px) => this.size(px), Size::Size(px) => this.size(px),
Size::XSmall => this.size_5(), Size::XSmall => this.size_5(),
Size::Small => this.size_6(), Size::Small => this.size_6(),
_ => this.size_8(), _ => this.size_9(),
} }
} else { } else {
// Normal Button // Normal Button

View File

@@ -1,9 +1,10 @@
use crate::theme::{scale::ColorScaleStep, ActiveTheme};
use gpui::{ use gpui::{
div, prelude::FluentBuilder as _, px, Axis, Div, Hsla, IntoElement, ParentElement, RenderOnce, div, prelude::FluentBuilder as _, px, Axis, Div, Hsla, IntoElement, ParentElement, RenderOnce,
SharedString, Styled, SharedString, Styled,
}; };
use crate::theme::{scale::ColorScaleStep, ActiveTheme};
/// A divider that can be either vertical or horizontal. /// A divider that can be either vertical or horizontal.
#[derive(IntoElement)] #[derive(IntoElement)]
pub struct Divider { pub struct Divider {

View File

@@ -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<Icon>,
anchor: Option<Corner>,
input: WeakEntity<TextInput>,
emojis: Rc<Vec<SharedString>>,
}
impl EmojiPicker {
pub fn new(input: WeakEntity<TextInput>) -> Self {
let mut emojis: Vec<SharedString> = vec![];
emojis.extend(
emojis::Group::SmileysAndEmotion
.emojis()
.map(|e| SharedString::from(e.as_str()))
.collect::<Vec<SharedString>>(),
);
emojis.extend(
emojis::Group::Symbols
.emojis()
.map(|e| SharedString::from(e.as_str()))
.collect::<Vec<SharedString>>(),
);
Self {
input,
emojis: emojis.into(),
anchor: None,
icon: None,
}
}
pub fn icon(mut self, icon: impl Into<Icon>) -> 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.))
})
})
}
}

View File

@@ -33,6 +33,7 @@ pub enum IconName {
Ellipsis, Ellipsis,
Eye, Eye,
EyeOff, EyeOff,
EmojiFill,
Folder, Folder,
FolderFill, FolderFill,
Inbox, Inbox,
@@ -96,6 +97,7 @@ impl IconName {
Self::EditFill => "icons/edit-fill.svg", Self::EditFill => "icons/edit-fill.svg",
Self::Ellipsis => "icons/ellipsis.svg", Self::Ellipsis => "icons/ellipsis.svg",
Self::Eye => "icons/eye.svg", Self::Eye => "icons/eye.svg",
Self::EmojiFill => "icons/emoji-fill.svg",
Self::EyeOff => "icons/eye-off.svg", Self::EyeOff => "icons/eye-off.svg",
Self::Folder => "icons/folder.svg", Self::Folder => "icons/folder.svg",
Self::FolderFill => "icons/folder-fill.svg", Self::FolderFill => "icons/folder-fill.svg",

View File

@@ -7,6 +7,7 @@ pub mod context_menu;
pub mod divider; pub mod divider;
pub mod dock_area; pub mod dock_area;
pub mod dropdown; pub mod dropdown;
pub mod emoji_picker;
pub mod history; pub mod history;
pub mod indicator; pub mod indicator;
pub mod input; pub mod input;

View File

@@ -1,12 +1,14 @@
use crate::{Selectable, StyledExt as _}; use std::{cell::RefCell, rc::Rc};
use gpui::{ use gpui::{
actions, anchored, deferred, div, prelude::FluentBuilder as _, px, AnyElement, App, Bounds, actions, anchored, deferred, div, prelude::FluentBuilder as _, px, AnyElement, App, Bounds,
Context, Corner, DismissEvent, DispatchPhase, Element, ElementId, Entity, EventEmitter, Context, Corner, DismissEvent, DispatchPhase, Element, ElementId, Entity, EventEmitter,
FocusHandle, Focusable, GlobalElementId, Hitbox, InteractiveElement as _, IntoElement, FocusHandle, Focusable, GlobalElementId, Hitbox, InteractiveElement as _, IntoElement,
KeyBinding, LayoutId, ManagedView, MouseButton, MouseDownEvent, ParentElement, Pixels, Point, 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"; const CONTEXT: &str = "Popover";
@@ -20,7 +22,10 @@ type PopoverChild<T> = Rc<dyn Fn(&mut Window, &mut Context<T>) -> AnyElement>;
pub struct PopoverContent { pub struct PopoverContent {
focus_handle: FocusHandle, focus_handle: FocusHandle,
scroll_handle: ScrollHandle,
max_width: Option<Pixels>, max_width: Option<Pixels>,
max_height: Option<Pixels>,
scrollable: bool,
child: PopoverChild<Self>, child: PopoverChild<Self>,
} }
@@ -30,11 +35,15 @@ impl PopoverContent {
B: Fn(&mut Window, &mut Context<Self>) -> AnyElement + 'static, B: Fn(&mut Window, &mut Context<Self>) -> AnyElement + 'static,
{ {
let focus_handle = cx.focus_handle(); let focus_handle = cx.focus_handle();
let scroll_handle = ScrollHandle::default();
Self { Self {
focus_handle, focus_handle,
scroll_handle,
child: Rc::new(content), child: Rc::new(content),
max_width: None, max_width: None,
max_height: None,
scrollable: false,
} }
} }
@@ -42,6 +51,16 @@ impl PopoverContent {
self.max_width = Some(max_width); self.max_width = Some(max_width);
self 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<DismissEvent> for PopoverContent {} impl EventEmitter<DismissEvent> for PopoverContent {}
@@ -55,11 +74,16 @@ impl Focusable for PopoverContent {
impl Render for PopoverContent { impl Render for PopoverContent {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
div() div()
.id("popup-content")
.track_focus(&self.focus_handle) .track_focus(&self.focus_handle)
.key_context(CONTEXT) .key_context(CONTEXT)
.on_action(cx.listener(|_, _: &Escape, _, cx| cx.emit(DismissEvent))) .on_action(cx.listener(|_, _: &Escape, _, cx| cx.emit(DismissEvent)))
.p_2() .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_width, |this, v| this.max_w(v))
.when_some(self.max_height, |this, v| this.max_h(v))
.child(self.child.clone()(window, cx)) .child(self.child.clone()(window, cx))
} }
} }

View File

@@ -64,7 +64,7 @@ pub trait StyledExt: Styled + Sized {
/// Set as Popover style /// Set as Popover style
fn popover_style(self, cx: &mut App) -> Self { 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_1()
.border_color(cx.theme().base.step(cx, ColorScaleStep::SIX)) .border_color(cx.theme().base.step(cx, ColorScaleStep::SIX))
.shadow_lg() .shadow_lg()