1use crate::{channel_view::ChannelView, ChatPanelSettings};
2use anyhow::Result;
3use call::ActiveCall;
4use channel::{ChannelChat, ChannelChatEvent, ChannelMessageId, ChannelStore};
5use client::Client;
6use db::kvp::KEY_VALUE_STORE;
7use editor::Editor;
8use feature_flags::{ChannelsAlpha, FeatureFlagAppExt};
9use gpui::{
10 actions,
11 elements::*,
12 platform::{CursorStyle, MouseButton},
13 serde_json,
14 views::{ItemType, Select, SelectStyle},
15 AnyViewHandle, AppContext, AsyncAppContext, Entity, ModelHandle, Subscription, Task, View,
16 ViewContext, ViewHandle, WeakViewHandle,
17};
18use language::language_settings::SoftWrap;
19use menu::Confirm;
20use project::Fs;
21use serde::{Deserialize, Serialize};
22use settings::SettingsStore;
23use std::sync::Arc;
24use theme::{IconButton, Theme};
25use time::{OffsetDateTime, UtcOffset};
26use util::{ResultExt, TryFutureExt};
27use workspace::{
28 dock::{DockPosition, Panel},
29 Workspace,
30};
31
32const MESSAGE_LOADING_THRESHOLD: usize = 50;
33const CHAT_PANEL_KEY: &'static str = "ChatPanel";
34
35pub struct ChatPanel {
36 client: Arc<Client>,
37 channel_store: ModelHandle<ChannelStore>,
38 active_chat: Option<(ModelHandle<ChannelChat>, Subscription)>,
39 message_list: ListState<ChatPanel>,
40 input_editor: ViewHandle<Editor>,
41 channel_select: ViewHandle<Select>,
42 local_timezone: UtcOffset,
43 fs: Arc<dyn Fs>,
44 width: Option<f32>,
45 pending_serialization: Task<Option<()>>,
46 subscriptions: Vec<gpui::Subscription>,
47 workspace: WeakViewHandle<Workspace>,
48 has_focus: bool,
49}
50
51#[derive(Serialize, Deserialize)]
52struct SerializedChatPanel {
53 width: Option<f32>,
54}
55
56#[derive(Debug)]
57pub enum Event {
58 DockPositionChanged,
59 Focus,
60 Dismissed,
61}
62
63actions!(
64 chat_panel,
65 [LoadMoreMessages, ToggleFocus, OpenChannelNotes, JoinCall]
66);
67
68pub fn init(cx: &mut AppContext) {
69 cx.add_action(ChatPanel::send);
70 cx.add_action(ChatPanel::load_more_messages);
71 cx.add_action(ChatPanel::open_notes);
72 cx.add_action(ChatPanel::join_call);
73}
74
75impl ChatPanel {
76 pub fn new(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) -> ViewHandle<Self> {
77 let fs = workspace.app_state().fs.clone();
78 let client = workspace.app_state().client.clone();
79 let channel_store = workspace.app_state().channel_store.clone();
80
81 let input_editor = cx.add_view(|cx| {
82 let mut editor = Editor::auto_height(
83 4,
84 Some(Arc::new(|theme| theme.chat_panel.input_editor.clone())),
85 cx,
86 );
87 editor.set_soft_wrap_mode(SoftWrap::EditorWidth, cx);
88 editor
89 });
90
91 let workspace_handle = workspace.weak_handle();
92
93 let channel_select = cx.add_view(|cx| {
94 let channel_store = channel_store.clone();
95 let workspace = workspace_handle.clone();
96 Select::new(0, cx, {
97 move |ix, item_type, is_hovered, cx| {
98 Self::render_channel_name(
99 &channel_store,
100 ix,
101 item_type,
102 is_hovered,
103 workspace,
104 cx,
105 )
106 }
107 })
108 .with_style(move |cx| {
109 let style = &theme::current(cx).chat_panel.channel_select;
110 SelectStyle {
111 header: Default::default(),
112 menu: style.menu,
113 }
114 })
115 });
116
117 let mut message_list =
118 ListState::<Self>::new(0, Orientation::Bottom, 1000., move |this, ix, cx| {
119 this.render_message(ix, cx)
120 });
121 message_list.set_scroll_handler(|visible_range, this, cx| {
122 if visible_range.start < MESSAGE_LOADING_THRESHOLD {
123 this.load_more_messages(&LoadMoreMessages, cx);
124 }
125 });
126
127 cx.add_view(|cx| {
128 let mut this = Self {
129 fs,
130 client,
131 channel_store,
132 active_chat: Default::default(),
133 pending_serialization: Task::ready(None),
134 message_list,
135 input_editor,
136 channel_select,
137 local_timezone: cx.platform().local_timezone(),
138 has_focus: false,
139 subscriptions: Vec::new(),
140 workspace: workspace_handle,
141 width: None,
142 };
143
144 let mut old_dock_position = this.position(cx);
145 this.subscriptions
146 .push(
147 cx.observe_global::<SettingsStore, _>(move |this: &mut Self, cx| {
148 let new_dock_position = this.position(cx);
149 if new_dock_position != old_dock_position {
150 old_dock_position = new_dock_position;
151 cx.emit(Event::DockPositionChanged);
152 }
153 cx.notify();
154 }),
155 );
156
157 this.init_active_channel(cx);
158 cx.observe(&this.channel_store, |this, _, cx| {
159 this.init_active_channel(cx);
160 })
161 .detach();
162
163 cx.observe(&this.channel_select, |this, channel_select, cx| {
164 let selected_ix = channel_select.read(cx).selected_index();
165
166 let selected_channel_id = this
167 .channel_store
168 .read(cx)
169 .channel_at(selected_ix)
170 .map(|e| e.id);
171 if let Some(selected_channel_id) = selected_channel_id {
172 this.select_channel(selected_channel_id, cx)
173 .detach_and_log_err(cx);
174 }
175 })
176 .detach();
177
178 this
179 })
180 }
181
182 pub fn load(
183 workspace: WeakViewHandle<Workspace>,
184 cx: AsyncAppContext,
185 ) -> Task<Result<ViewHandle<Self>>> {
186 cx.spawn(|mut cx| async move {
187 let serialized_panel = if let Some(panel) = cx
188 .background()
189 .spawn(async move { KEY_VALUE_STORE.read_kvp(CHAT_PANEL_KEY) })
190 .await
191 .log_err()
192 .flatten()
193 {
194 Some(serde_json::from_str::<SerializedChatPanel>(&panel)?)
195 } else {
196 None
197 };
198
199 workspace.update(&mut cx, |workspace, cx| {
200 let panel = Self::new(workspace, cx);
201 if let Some(serialized_panel) = serialized_panel {
202 panel.update(cx, |panel, cx| {
203 panel.width = serialized_panel.width;
204 cx.notify();
205 });
206 }
207 panel
208 })
209 })
210 }
211
212 fn serialize(&mut self, cx: &mut ViewContext<Self>) {
213 let width = self.width;
214 self.pending_serialization = cx.background().spawn(
215 async move {
216 KEY_VALUE_STORE
217 .write_kvp(
218 CHAT_PANEL_KEY.into(),
219 serde_json::to_string(&SerializedChatPanel { width })?,
220 )
221 .await?;
222 anyhow::Ok(())
223 }
224 .log_err(),
225 );
226 }
227
228 fn init_active_channel(&mut self, cx: &mut ViewContext<Self>) {
229 let channel_count = self.channel_store.read(cx).channel_count();
230 self.message_list.reset(0);
231 self.active_chat = None;
232 self.channel_select.update(cx, |select, cx| {
233 select.set_item_count(channel_count, cx);
234 });
235 }
236
237 fn set_active_chat(&mut self, chat: ModelHandle<ChannelChat>, cx: &mut ViewContext<Self>) {
238 if self.active_chat.as_ref().map(|e| &e.0) != Some(&chat) {
239 let id = chat.read(cx).channel().id;
240 {
241 let chat = chat.read(cx);
242 self.message_list.reset(chat.message_count());
243 let placeholder = format!("Message #{}", chat.channel().name);
244 self.input_editor.update(cx, move |editor, cx| {
245 editor.set_placeholder_text(placeholder, cx);
246 });
247 }
248 let subscription = cx.subscribe(&chat, Self::channel_did_change);
249 self.active_chat = Some((chat, subscription));
250 self.channel_select.update(cx, |select, cx| {
251 if let Some(ix) = self.channel_store.read(cx).index_of_channel(id) {
252 select.set_selected_index(ix, cx);
253 }
254 });
255 cx.notify();
256 }
257 }
258
259 fn channel_did_change(
260 &mut self,
261 _: ModelHandle<ChannelChat>,
262 event: &ChannelChatEvent,
263 cx: &mut ViewContext<Self>,
264 ) {
265 match event {
266 ChannelChatEvent::MessagesUpdated {
267 old_range,
268 new_count,
269 } => {
270 self.message_list.splice(old_range.clone(), *new_count);
271 }
272 }
273 cx.notify();
274 }
275
276 fn render_channel(&self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
277 let theme = theme::current(cx);
278 Flex::column()
279 .with_child(
280 ChildView::new(&self.channel_select, cx)
281 .contained()
282 .with_style(theme.chat_panel.channel_select.container),
283 )
284 .with_child(self.render_active_channel_messages(&theme))
285 .with_child(self.render_input_box(&theme, cx))
286 .into_any()
287 }
288
289 fn render_active_channel_messages(&self, theme: &Arc<Theme>) -> AnyElement<Self> {
290 let messages = if self.active_chat.is_some() {
291 List::new(self.message_list.clone())
292 .contained()
293 .with_style(theme.chat_panel.list)
294 .into_any()
295 } else {
296 Empty::new().into_any()
297 };
298
299 messages.flex(1., true).into_any()
300 }
301
302 fn render_message(&self, ix: usize, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
303 let message = self.active_chat.as_ref().unwrap().0.read(cx).message(ix);
304
305 let now = OffsetDateTime::now_utc();
306 let theme = theme::current(cx);
307 let style = if message.is_pending() {
308 &theme.chat_panel.pending_message
309 } else {
310 &theme.chat_panel.message
311 };
312
313 let belongs_to_user = Some(message.sender.id) == self.client.user_id();
314 let message_id_to_remove =
315 if let (ChannelMessageId::Saved(id), true) = (message.id, belongs_to_user) {
316 Some(id)
317 } else {
318 None
319 };
320
321 enum DeleteMessage {}
322
323 let body = message.body.clone();
324 Flex::column()
325 .with_child(
326 Flex::row()
327 .with_child(
328 Label::new(
329 message.sender.github_login.clone(),
330 style.sender.text.clone(),
331 )
332 .contained()
333 .with_style(style.sender.container),
334 )
335 .with_child(
336 Label::new(
337 format_timestamp(message.timestamp, now, self.local_timezone),
338 style.timestamp.text.clone(),
339 )
340 .contained()
341 .with_style(style.timestamp.container),
342 )
343 .with_children(message_id_to_remove.map(|id| {
344 MouseEventHandler::new::<DeleteMessage, _>(
345 id as usize,
346 cx,
347 |mouse_state, _| {
348 let button_style =
349 theme.chat_panel.icon_button.style_for(mouse_state);
350 render_icon_button(button_style, "icons/x.svg")
351 .aligned()
352 .into_any()
353 },
354 )
355 .with_padding(Padding::uniform(2.))
356 .with_cursor_style(CursorStyle::PointingHand)
357 .on_click(MouseButton::Left, move |_, this, cx| {
358 this.remove_message(id, cx);
359 })
360 .flex_float()
361 })),
362 )
363 .with_child(Text::new(body, style.body.clone()))
364 .contained()
365 .with_style(style.container)
366 .into_any()
367 }
368
369 fn render_input_box(&self, theme: &Arc<Theme>, cx: &AppContext) -> AnyElement<Self> {
370 ChildView::new(&self.input_editor, cx)
371 .contained()
372 .with_style(theme.chat_panel.input_editor.container)
373 .into_any()
374 }
375
376 fn render_channel_name(
377 channel_store: &ModelHandle<ChannelStore>,
378 ix: usize,
379 item_type: ItemType,
380 is_hovered: bool,
381 workspace: WeakViewHandle<Workspace>,
382 cx: &mut ViewContext<Select>,
383 ) -> AnyElement<Select> {
384 let theme = theme::current(cx);
385 let tooltip_style = &theme.tooltip;
386 let theme = &theme.chat_panel;
387 let style = match (&item_type, is_hovered) {
388 (ItemType::Header, _) => &theme.channel_select.header,
389 (ItemType::Selected, _) => &theme.channel_select.active_item,
390 (ItemType::Unselected, false) => &theme.channel_select.item,
391 (ItemType::Unselected, true) => &theme.channel_select.hovered_item,
392 };
393
394 let channel = &channel_store.read(cx).channel_at(ix).unwrap();
395 let channel_id = channel.id;
396
397 let mut row = Flex::row()
398 .with_child(
399 Label::new("#".to_string(), style.hash.text.clone())
400 .contained()
401 .with_style(style.hash.container),
402 )
403 .with_child(Label::new(channel.name.clone(), style.name.clone()));
404
405 if matches!(item_type, ItemType::Header) {
406 row.add_children([
407 MouseEventHandler::new::<OpenChannelNotes, _>(0, cx, |mouse_state, _| {
408 render_icon_button(theme.icon_button.style_for(mouse_state), "icons/file.svg")
409 })
410 .on_click(MouseButton::Left, move |_, _, cx| {
411 if let Some(workspace) = workspace.upgrade(cx) {
412 ChannelView::deploy(channel_id, workspace, cx);
413 }
414 })
415 .with_tooltip::<OpenChannelNotes>(
416 channel_id as usize,
417 "Open Notes",
418 Some(Box::new(OpenChannelNotes)),
419 tooltip_style.clone(),
420 cx,
421 )
422 .flex_float(),
423 MouseEventHandler::new::<ActiveCall, _>(0, cx, |mouse_state, _| {
424 render_icon_button(
425 theme.icon_button.style_for(mouse_state),
426 "icons/speaker-loud.svg",
427 )
428 })
429 .on_click(MouseButton::Left, move |_, _, cx| {
430 ActiveCall::global(cx)
431 .update(cx, |call, cx| call.join_channel(channel_id, cx))
432 .detach_and_log_err(cx);
433 })
434 .with_tooltip::<ActiveCall>(
435 channel_id as usize,
436 "Join Call",
437 Some(Box::new(JoinCall)),
438 tooltip_style.clone(),
439 cx,
440 )
441 .flex_float(),
442 ]);
443 }
444
445 row.align_children_center()
446 .contained()
447 .with_style(style.container)
448 .into_any()
449 }
450
451 fn render_sign_in_prompt(
452 &self,
453 theme: &Arc<Theme>,
454 cx: &mut ViewContext<Self>,
455 ) -> AnyElement<Self> {
456 enum SignInPromptLabel {}
457
458 MouseEventHandler::new::<SignInPromptLabel, _>(0, cx, |mouse_state, _| {
459 Label::new(
460 "Sign in to use chat".to_string(),
461 theme
462 .chat_panel
463 .sign_in_prompt
464 .style_for(mouse_state)
465 .clone(),
466 )
467 })
468 .with_cursor_style(CursorStyle::PointingHand)
469 .on_click(MouseButton::Left, move |_, this, cx| {
470 let client = this.client.clone();
471 cx.spawn(|this, mut cx| async move {
472 if client
473 .authenticate_and_connect(true, &cx)
474 .log_err()
475 .await
476 .is_some()
477 {
478 this.update(&mut cx, |this, cx| {
479 if cx.handle().is_focused(cx) {
480 cx.focus(&this.input_editor);
481 }
482 })
483 .ok();
484 }
485 })
486 .detach();
487 })
488 .aligned()
489 .into_any()
490 }
491
492 fn send(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
493 if let Some((chat, _)) = self.active_chat.as_ref() {
494 let body = self.input_editor.update(cx, |editor, cx| {
495 let body = editor.text(cx);
496 editor.clear(cx);
497 body
498 });
499
500 if let Some(task) = chat
501 .update(cx, |chat, cx| chat.send_message(body, cx))
502 .log_err()
503 {
504 task.detach();
505 }
506 }
507 }
508
509 fn remove_message(&mut self, id: u64, cx: &mut ViewContext<Self>) {
510 if let Some((chat, _)) = self.active_chat.as_ref() {
511 chat.update(cx, |chat, cx| chat.remove_message(id, cx).detach())
512 }
513 }
514
515 fn load_more_messages(&mut self, _: &LoadMoreMessages, cx: &mut ViewContext<Self>) {
516 if let Some((chat, _)) = self.active_chat.as_ref() {
517 chat.update(cx, |channel, cx| {
518 channel.load_more_messages(cx);
519 })
520 }
521 }
522
523 pub fn select_channel(
524 &mut self,
525 selected_channel_id: u64,
526 cx: &mut ViewContext<ChatPanel>,
527 ) -> Task<Result<()>> {
528 if let Some((chat, _)) = &self.active_chat {
529 if chat.read(cx).channel().id == selected_channel_id {
530 return Task::ready(Ok(()));
531 }
532 }
533
534 let open_chat = self.channel_store.update(cx, |store, cx| {
535 store.open_channel_chat(selected_channel_id, cx)
536 });
537 cx.spawn(|this, mut cx| async move {
538 let chat = open_chat.await?;
539 this.update(&mut cx, |this, cx| {
540 this.set_active_chat(chat, cx);
541 })
542 })
543 }
544
545 fn open_notes(&mut self, _: &OpenChannelNotes, cx: &mut ViewContext<Self>) {
546 if let Some((chat, _)) = &self.active_chat {
547 let channel_id = chat.read(cx).channel().id;
548 if let Some(workspace) = self.workspace.upgrade(cx) {
549 ChannelView::deploy(channel_id, workspace, cx);
550 }
551 }
552 }
553
554 fn join_call(&mut self, _: &JoinCall, cx: &mut ViewContext<Self>) {
555 if let Some((chat, _)) = &self.active_chat {
556 let channel_id = chat.read(cx).channel().id;
557 ActiveCall::global(cx)
558 .update(cx, |call, cx| call.join_channel(channel_id, cx))
559 .detach_and_log_err(cx);
560 }
561 }
562}
563
564impl Entity for ChatPanel {
565 type Event = Event;
566}
567
568impl View for ChatPanel {
569 fn ui_name() -> &'static str {
570 "ChatPanel"
571 }
572
573 fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
574 let theme = theme::current(cx);
575 let element = if self.client.user_id().is_some() {
576 self.render_channel(cx)
577 } else {
578 self.render_sign_in_prompt(&theme, cx)
579 };
580 element
581 .contained()
582 .with_style(theme.chat_panel.container)
583 .constrained()
584 .with_min_width(150.)
585 .into_any()
586 }
587
588 fn focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {
589 self.has_focus = true;
590 if matches!(
591 *self.client.status().borrow(),
592 client::Status::Connected { .. }
593 ) {
594 cx.focus(&self.input_editor);
595 }
596 }
597
598 fn focus_out(&mut self, _: AnyViewHandle, _: &mut ViewContext<Self>) {
599 self.has_focus = false;
600 }
601}
602
603impl Panel for ChatPanel {
604 fn position(&self, cx: &gpui::WindowContext) -> DockPosition {
605 settings::get::<ChatPanelSettings>(cx).dock
606 }
607
608 fn position_is_valid(&self, position: DockPosition) -> bool {
609 matches!(position, DockPosition::Left | DockPosition::Right)
610 }
611
612 fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
613 settings::update_settings_file::<ChatPanelSettings>(self.fs.clone(), cx, move |settings| {
614 settings.dock = Some(position)
615 });
616 }
617
618 fn size(&self, cx: &gpui::WindowContext) -> f32 {
619 self.width
620 .unwrap_or_else(|| settings::get::<ChatPanelSettings>(cx).default_width)
621 }
622
623 fn set_size(&mut self, size: Option<f32>, cx: &mut ViewContext<Self>) {
624 self.width = size;
625 self.serialize(cx);
626 cx.notify();
627 }
628
629 fn set_active(&mut self, active: bool, cx: &mut ViewContext<Self>) {
630 if active && !is_chat_feature_enabled(cx) {
631 cx.emit(Event::Dismissed);
632 }
633 }
634
635 fn icon_path(&self, cx: &gpui::WindowContext) -> Option<&'static str> {
636 (settings::get::<ChatPanelSettings>(cx).button && is_chat_feature_enabled(cx))
637 .then(|| "icons/conversations.svg")
638 }
639
640 fn icon_tooltip(&self) -> (String, Option<Box<dyn gpui::Action>>) {
641 ("Chat Panel".to_string(), Some(Box::new(ToggleFocus)))
642 }
643
644 fn should_change_position_on_event(event: &Self::Event) -> bool {
645 matches!(event, Event::DockPositionChanged)
646 }
647
648 fn should_close_on_event(event: &Self::Event) -> bool {
649 matches!(event, Event::Dismissed)
650 }
651
652 fn has_focus(&self, _cx: &gpui::WindowContext) -> bool {
653 self.has_focus
654 }
655
656 fn is_focus_event(event: &Self::Event) -> bool {
657 matches!(event, Event::Focus)
658 }
659}
660
661fn is_chat_feature_enabled(cx: &gpui::WindowContext<'_>) -> bool {
662 cx.is_staff() || cx.has_flag::<ChannelsAlpha>()
663}
664
665fn format_timestamp(
666 mut timestamp: OffsetDateTime,
667 mut now: OffsetDateTime,
668 local_timezone: UtcOffset,
669) -> String {
670 timestamp = timestamp.to_offset(local_timezone);
671 now = now.to_offset(local_timezone);
672
673 let today = now.date();
674 let date = timestamp.date();
675 let mut hour = timestamp.hour();
676 let mut part = "am";
677 if hour > 12 {
678 hour -= 12;
679 part = "pm";
680 }
681 if date == today {
682 format!("{:02}:{:02}{}", hour, timestamp.minute(), part)
683 } else if date.next_day() == Some(today) {
684 format!("yesterday at {:02}:{:02}{}", hour, timestamp.minute(), part)
685 } else {
686 format!("{:02}/{}/{}", date.month() as u32, date.day(), date.year())
687 }
688}
689
690fn render_icon_button<V: View>(style: &IconButton, svg_path: &'static str) -> impl Element<V> {
691 Svg::new(svg_path)
692 .with_color(style.color)
693 .constrained()
694 .with_width(style.icon_width)
695 .aligned()
696 .constrained()
697 .with_width(style.button_width)
698 .with_height(style.button_width)
699 .contained()
700 .with_style(style.container)
701}