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