wip
Some checks failed
Rust / build (ubuntu-latest, stable) (push) Failing after 1m59s
Some checks failed
Rust / build (ubuntu-latest, stable) (push) Failing after 1m59s
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.))
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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| {
|
||||
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<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(
|
||||
@@ -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);
|
||||
})),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.))
|
||||
|
||||
Reference in New Issue
Block a user