feat: revamp the chat panel ui #7

Merged
reya merged 9 commits from revamp-chat-ui into master 2026-02-19 07:25:08 +00:00
8 changed files with 153 additions and 230 deletions
Showing only changes of commit a1aaa30a48 - Show all commits

10
Cargo.lock generated
View File

@@ -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"

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M2.66936 5.12886C2.12122 3.64104 3.6759 2.24953 5.09409 2.95862L20.0468 10.435C21.3366 11.0799 21.3366 12.9205 20.0468 13.5655L5.09409 21.0418C3.67589 21.7509 2.12122 20.3594 2.66936 18.8715L4.92467 12.75H9.25021C9.66442 12.75 10.0002 12.4142 10.0002 12C10.0002 11.5858 9.66442 11.25 9.25021 11.25H4.92452L2.66936 5.12886Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 435 B

View File

@@ -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"

View File

@@ -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);

View File

@@ -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<Vec<SharedString>> = OnceLock::new();
fn get_emojis() -> &'static Vec<SharedString> {
EMOJIS.get_or_init(|| {
let mut emojis: Vec<SharedString> = vec![];
emojis.extend(
emojis::Group::SmileysAndEmotion
.emojis()
.map(|e| SharedString::from(e.as_str()))
.collect::<Vec<SharedString>>(),
);
emojis
})
}
#[derive(IntoElement)]
pub struct EmojiPicker {
target: Option<WeakEntity<InputState>>,
icon: Option<Icon>,
anchor: Option<Corner>,
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<InputState>) -> Self {
self.target = Some(target);
self
}
pub fn icon(mut self, icon: impl Into<Icon>) -> 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<Size>) -> 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.))
})
})
}
}

View File

@@ -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<Room>, window: &mut Window, cx: &mut App) -> Entity<ChatPanel> {
@@ -87,28 +85,40 @@ pub struct ChatPanel {
impl ChatPanel {
pub fn new(room: WeakEntity<Room>, window: &mut Window, cx: &mut Context<Self>) -> 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| {
cx.subscribe_in(&input, window, move |this, _input, event, window, cx| {
if let InputEvent::PressEnter { .. } = event {
this.send_message(window, cx);
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<Self>) -> String {
fn get_input_value(&self, cx: &Context<Self>) -> 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<Self>) {
fn send_input_message(&mut self, window: &mut Window, cx: &mut Context<Self>) {
// 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<Self>) {
// Get replies_to if it's present
let replies: Vec<EventId> = 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<Self>) -> 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<Self>) {
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<Self>) -> impl IntoElement {
v_flex()
.on_action(cx.listener(Self::on_command))
.image_cache(self.image_cache.clone())
.size_full()
.child(
@@ -1162,49 +1186,69 @@ impl Render for ChatPanel {
)
.flex_1(),
)
.child(
div()
.flex_shrink_0()
.w_full()
.relative()
.px_3()
.py_2()
.child(
v_flex()
.flex_shrink_0()
.p_2()
.w_full()
.gap_1p5()
.children(self.render_attachment_list(window, cx))
.children(self.render_reply_list(window, cx))
.child(
div()
.w_full()
.flex()
.items_end()
.gap_2p5()
.child(
h_flex()
.gap_1()
.text_color(cx.theme().text_muted)
.items_end()
.child(
Button::new("upload")
.icon(IconName::Upload)
.icon(IconName::Plus)
.tooltip("Upload media")
.loading(self.uploading)
.disabled(self.uploading)
.ghost()
.large()
.on_click(cx.listener(
move |this, _, window, cx| {
.on_click(cx.listener(move |this, _ev, window, cx| {
this.upload(window, cx);
},
)),
})),
)
.child(
EmojiPicker::new()
.target(self.input.downgrade())
TextInput::new(&self.input)
.appearance(false)
.flex_1()
.text_sm(),
)
.child(
h_flex()
.pl_1()
.gap_1()
.child(
Button::new("emoji")
.icon(IconName::Emoji)
.large(),
.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);
})),
),
),
),
)

View File

@@ -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",

View File

@@ -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<PopupMenuItem>,
/// The focus handle of Entity to handle actions.
pub(crate) action_context: Option<FocusHandle>,
axis_items: Axis,
has_icon: bool,
selected_index: Option<usize>,
min_width: Option<Pixels>,
@@ -126,13 +128,14 @@ pub struct PopupMenu {
max_height: Option<Pixels>,
bounds: Bounds<Pixels>,
size: Size,
/// The parent menu of this menu, if this is a submenu
parent_menu: Option<WeakEntity<Self>>,
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<WeakEntity<Self>>,
// 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<Pixels>) -> 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.))