1use crate::{
2 channel_view::ChannelView, is_channels_feature_enabled, render_avatar, ChatPanelSettings,
3};
4use anyhow::Result;
5use call::ActiveCall;
6use channel::{ChannelChat, ChannelChatEvent, ChannelMessageId, ChannelStore};
7use client::Client;
8use collections::HashMap;
9use db::kvp::KEY_VALUE_STORE;
10use editor::Editor;
11use gpui::{
12 actions,
13 elements::*,
14 platform::{CursorStyle, MouseButton},
15 serde_json,
16 views::{ItemType, Select, SelectStyle},
17 AnyViewHandle, AppContext, AsyncAppContext, Entity, ModelHandle, Subscription, Task, View,
18 ViewContext, ViewHandle, WeakViewHandle,
19};
20use language::LanguageRegistry;
21use menu::Confirm;
22use message_editor::MessageEditor;
23use project::Fs;
24use rich_text::RichText;
25use serde::{Deserialize, Serialize};
26use settings::SettingsStore;
27use std::sync::Arc;
28use theme::{IconButton, Theme};
29use time::{OffsetDateTime, UtcOffset};
30use util::{ResultExt, TryFutureExt};
31use workspace::{
32 dock::{DockPosition, Panel},
33 Workspace,
34};
35
36mod message_editor;
37
38const MESSAGE_LOADING_THRESHOLD: usize = 50;
39const CHAT_PANEL_KEY: &'static str = "ChatPanel";
40
41pub struct ChatPanel {
42 client: Arc<Client>,
43 channel_store: ModelHandle<ChannelStore>,
44 languages: Arc<LanguageRegistry>,
45 active_chat: Option<(ModelHandle<ChannelChat>, Subscription)>,
46 message_list: ListState<ChatPanel>,
47 input_editor: ViewHandle<MessageEditor>,
48 channel_select: ViewHandle<Select>,
49 local_timezone: UtcOffset,
50 fs: Arc<dyn Fs>,
51 width: Option<f32>,
52 active: bool,
53 pending_serialization: Task<Option<()>>,
54 subscriptions: Vec<gpui::Subscription>,
55 workspace: WeakViewHandle<Workspace>,
56 is_scrolled_to_bottom: bool,
57 has_focus: bool,
58 markdown_data: HashMap<ChannelMessageId, RichText>,
59}
60
61#[derive(Serialize, Deserialize)]
62struct SerializedChatPanel {
63 width: Option<f32>,
64}
65
66#[derive(Debug)]
67pub enum Event {
68 DockPositionChanged,
69 Focus,
70 Dismissed,
71}
72
73actions!(
74 chat_panel,
75 [LoadMoreMessages, ToggleFocus, OpenChannelNotes, JoinCall]
76);
77
78pub fn init(cx: &mut AppContext) {
79 cx.add_action(ChatPanel::send);
80 cx.add_action(ChatPanel::load_more_messages);
81 cx.add_action(ChatPanel::open_notes);
82 cx.add_action(ChatPanel::join_call);
83}
84
85impl ChatPanel {
86 pub fn new(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) -> ViewHandle<Self> {
87 let fs = workspace.app_state().fs.clone();
88 let client = workspace.app_state().client.clone();
89 let channel_store = ChannelStore::global(cx);
90 let languages = workspace.app_state().languages.clone();
91
92 let input_editor = cx.add_view(|cx| {
93 MessageEditor::new(
94 languages.clone(),
95 channel_store.clone(),
96 cx.add_view(|cx| {
97 Editor::auto_height(
98 4,
99 Some(Arc::new(|theme| theme.chat_panel.input_editor.clone())),
100 cx,
101 )
102 }),
103 cx,
104 )
105 });
106
107 let workspace_handle = workspace.weak_handle();
108
109 let channel_select = cx.add_view(|cx| {
110 let channel_store = channel_store.clone();
111 let workspace = workspace_handle.clone();
112 Select::new(0, cx, {
113 move |ix, item_type, is_hovered, cx| {
114 Self::render_channel_name(
115 &channel_store,
116 ix,
117 item_type,
118 is_hovered,
119 workspace,
120 cx,
121 )
122 }
123 })
124 .with_style(move |cx| {
125 let style = &theme::current(cx).chat_panel.channel_select;
126 SelectStyle {
127 header: Default::default(),
128 menu: style.menu,
129 }
130 })
131 });
132
133 let mut message_list =
134 ListState::<Self>::new(0, Orientation::Bottom, 10., move |this, ix, cx| {
135 this.render_message(ix, cx)
136 });
137 message_list.set_scroll_handler(|visible_range, count, this, cx| {
138 if visible_range.start < MESSAGE_LOADING_THRESHOLD {
139 this.load_more_messages(&LoadMoreMessages, cx);
140 }
141 this.is_scrolled_to_bottom = visible_range.end == count;
142 });
143
144 cx.add_view(|cx| {
145 let mut this = Self {
146 fs,
147 client,
148 channel_store,
149 languages,
150 active_chat: Default::default(),
151 pending_serialization: Task::ready(None),
152 message_list,
153 input_editor,
154 channel_select,
155 local_timezone: cx.platform().local_timezone(),
156 has_focus: false,
157 subscriptions: Vec::new(),
158 workspace: workspace_handle,
159 is_scrolled_to_bottom: true,
160 active: false,
161 width: None,
162 markdown_data: Default::default(),
163 };
164
165 let mut old_dock_position = this.position(cx);
166 this.subscriptions
167 .push(
168 cx.observe_global::<SettingsStore, _>(move |this: &mut Self, cx| {
169 let new_dock_position = this.position(cx);
170 if new_dock_position != old_dock_position {
171 old_dock_position = new_dock_position;
172 cx.emit(Event::DockPositionChanged);
173 }
174 cx.notify();
175 }),
176 );
177
178 this.update_channel_count(cx);
179 cx.observe(&this.channel_store, |this, _, cx| {
180 this.update_channel_count(cx)
181 })
182 .detach();
183
184 cx.observe(&this.channel_select, |this, channel_select, cx| {
185 let selected_ix = channel_select.read(cx).selected_index();
186
187 let selected_channel_id = this
188 .channel_store
189 .read(cx)
190 .channel_at(selected_ix)
191 .map(|e| e.id);
192 if let Some(selected_channel_id) = selected_channel_id {
193 this.select_channel(selected_channel_id, None, cx)
194 .detach_and_log_err(cx);
195 }
196 })
197 .detach();
198
199 this
200 })
201 }
202
203 pub fn is_scrolled_to_bottom(&self) -> bool {
204 self.is_scrolled_to_bottom
205 }
206
207 pub fn active_chat(&self) -> Option<ModelHandle<ChannelChat>> {
208 self.active_chat.as_ref().map(|(chat, _)| chat.clone())
209 }
210
211 pub fn load(
212 workspace: WeakViewHandle<Workspace>,
213 cx: AsyncAppContext,
214 ) -> Task<Result<ViewHandle<Self>>> {
215 cx.spawn(|mut cx| async move {
216 let serialized_panel = if let Some(panel) = cx
217 .background()
218 .spawn(async move { KEY_VALUE_STORE.read_kvp(CHAT_PANEL_KEY) })
219 .await
220 .log_err()
221 .flatten()
222 {
223 Some(serde_json::from_str::<SerializedChatPanel>(&panel)?)
224 } else {
225 None
226 };
227
228 workspace.update(&mut cx, |workspace, cx| {
229 let panel = Self::new(workspace, cx);
230 if let Some(serialized_panel) = serialized_panel {
231 panel.update(cx, |panel, cx| {
232 panel.width = serialized_panel.width;
233 cx.notify();
234 });
235 }
236 panel
237 })
238 })
239 }
240
241 fn serialize(&mut self, cx: &mut ViewContext<Self>) {
242 let width = self.width;
243 self.pending_serialization = cx.background().spawn(
244 async move {
245 KEY_VALUE_STORE
246 .write_kvp(
247 CHAT_PANEL_KEY.into(),
248 serde_json::to_string(&SerializedChatPanel { width })?,
249 )
250 .await?;
251 anyhow::Ok(())
252 }
253 .log_err(),
254 );
255 }
256
257 fn update_channel_count(&mut self, cx: &mut ViewContext<Self>) {
258 let channel_count = self.channel_store.read(cx).channel_count();
259 self.channel_select.update(cx, |select, cx| {
260 select.set_item_count(channel_count, cx);
261 });
262 }
263
264 fn set_active_chat(&mut self, chat: ModelHandle<ChannelChat>, cx: &mut ViewContext<Self>) {
265 if self.active_chat.as_ref().map(|e| &e.0) != Some(&chat) {
266 self.markdown_data.clear();
267 let id = {
268 let chat = chat.read(cx);
269 let channel = chat.channel().clone();
270 self.message_list.reset(chat.message_count());
271 self.input_editor.update(cx, |editor, cx| {
272 editor.set_channel(channel.clone(), cx);
273 });
274 channel.id
275 };
276 let subscription = cx.subscribe(&chat, Self::channel_did_change);
277 self.active_chat = Some((chat, subscription));
278 self.acknowledge_last_message(cx);
279 self.channel_select.update(cx, |select, cx| {
280 if let Some(ix) = self.channel_store.read(cx).index_of_channel(id) {
281 select.set_selected_index(ix, cx);
282 }
283 });
284 cx.notify();
285 }
286 }
287
288 fn channel_did_change(
289 &mut self,
290 _: ModelHandle<ChannelChat>,
291 event: &ChannelChatEvent,
292 cx: &mut ViewContext<Self>,
293 ) {
294 match event {
295 ChannelChatEvent::MessagesUpdated {
296 old_range,
297 new_count,
298 } => {
299 self.message_list.splice(old_range.clone(), *new_count);
300 if self.active {
301 self.acknowledge_last_message(cx);
302 }
303 }
304 ChannelChatEvent::NewMessage {
305 channel_id,
306 message_id,
307 } => {
308 if !self.active {
309 self.channel_store.update(cx, |store, cx| {
310 store.new_message(*channel_id, *message_id, cx)
311 })
312 }
313 }
314 }
315 cx.notify();
316 }
317
318 fn acknowledge_last_message(&mut self, cx: &mut ViewContext<'_, '_, ChatPanel>) {
319 if self.active && self.is_scrolled_to_bottom {
320 if let Some((chat, _)) = &self.active_chat {
321 chat.update(cx, |chat, cx| {
322 chat.acknowledge_last_message(cx);
323 });
324 }
325 }
326 }
327
328 fn render_channel(&self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
329 let theme = theme::current(cx);
330 Flex::column()
331 .with_child(
332 ChildView::new(&self.channel_select, cx)
333 .contained()
334 .with_style(theme.chat_panel.channel_select.container),
335 )
336 .with_child(self.render_active_channel_messages(&theme))
337 .with_child(self.render_input_box(&theme, cx))
338 .into_any()
339 }
340
341 fn render_active_channel_messages(&self, theme: &Arc<Theme>) -> AnyElement<Self> {
342 let messages = if self.active_chat.is_some() {
343 List::new(self.message_list.clone())
344 .contained()
345 .with_style(theme.chat_panel.list)
346 .into_any()
347 } else {
348 Empty::new().into_any()
349 };
350
351 messages.flex(1., true).into_any()
352 }
353
354 fn render_message(&mut self, ix: usize, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
355 let (message, is_continuation, is_last, is_admin) = self
356 .active_chat
357 .as_ref()
358 .unwrap()
359 .0
360 .update(cx, |active_chat, cx| {
361 let is_admin = self
362 .channel_store
363 .read(cx)
364 .is_user_admin(active_chat.channel().id);
365 let last_message = active_chat.message(ix.saturating_sub(1));
366 let this_message = active_chat.message(ix).clone();
367 let is_continuation = last_message.id != this_message.id
368 && this_message.sender.id == last_message.sender.id;
369
370 if let ChannelMessageId::Saved(id) = this_message.id {
371 if this_message
372 .mentions
373 .iter()
374 .any(|(_, user_id)| Some(*user_id) == self.client.user_id())
375 {
376 active_chat.acknowledge_message(id);
377 }
378 }
379
380 (
381 this_message,
382 is_continuation,
383 active_chat.message_count() == ix + 1,
384 is_admin,
385 )
386 });
387
388 let is_pending = message.is_pending();
389 let theme = theme::current(cx);
390 let text = self.markdown_data.entry(message.id).or_insert_with(|| {
391 Self::render_markdown_with_mentions(&self.languages, self.client.id(), &message)
392 });
393
394 let now = OffsetDateTime::now_utc();
395
396 let style = if is_pending {
397 &theme.chat_panel.pending_message
398 } else if is_continuation {
399 &theme.chat_panel.continuation_message
400 } else {
401 &theme.chat_panel.message
402 };
403
404 let belongs_to_user = Some(message.sender.id) == self.client.user_id();
405 let message_id_to_remove = if let (ChannelMessageId::Saved(id), true) =
406 (message.id, belongs_to_user || is_admin)
407 {
408 Some(id)
409 } else {
410 None
411 };
412
413 enum MessageBackgroundHighlight {}
414 MouseEventHandler::new::<MessageBackgroundHighlight, _>(ix, cx, |state, cx| {
415 let container = style.style_for(state);
416 if is_continuation {
417 Flex::row()
418 .with_child(
419 text.element(
420 theme.editor.syntax.clone(),
421 theme.chat_panel.rich_text.clone(),
422 cx,
423 )
424 .flex(1., true),
425 )
426 .with_child(render_remove(message_id_to_remove, cx, &theme))
427 .contained()
428 .with_style(*container)
429 .with_margin_bottom(if is_last {
430 theme.chat_panel.last_message_bottom_spacing
431 } else {
432 0.
433 })
434 .into_any()
435 } else {
436 Flex::column()
437 .with_child(
438 Flex::row()
439 .with_child(
440 Flex::row()
441 .with_child(render_avatar(
442 message.sender.avatar.clone(),
443 &theme.chat_panel.avatar,
444 theme.chat_panel.avatar_container,
445 ))
446 .with_child(
447 Label::new(
448 message.sender.github_login.clone(),
449 theme.chat_panel.message_sender.text.clone(),
450 )
451 .contained()
452 .with_style(theme.chat_panel.message_sender.container),
453 )
454 .with_child(
455 Label::new(
456 format_timestamp(
457 message.timestamp,
458 now,
459 self.local_timezone,
460 ),
461 theme.chat_panel.message_timestamp.text.clone(),
462 )
463 .contained()
464 .with_style(theme.chat_panel.message_timestamp.container),
465 )
466 .align_children_center()
467 .flex(1., true),
468 )
469 .with_child(render_remove(message_id_to_remove, cx, &theme))
470 .align_children_center(),
471 )
472 .with_child(
473 Flex::row()
474 .with_child(
475 text.element(
476 theme.editor.syntax.clone(),
477 theme.chat_panel.rich_text.clone(),
478 cx,
479 )
480 .flex(1., true),
481 )
482 // Add a spacer to make everything line up
483 .with_child(render_remove(None, cx, &theme)),
484 )
485 .contained()
486 .with_style(*container)
487 .with_margin_bottom(if is_last {
488 theme.chat_panel.last_message_bottom_spacing
489 } else {
490 0.
491 })
492 .into_any()
493 }
494 })
495 .into_any()
496 }
497
498 fn render_markdown_with_mentions(
499 language_registry: &Arc<LanguageRegistry>,
500 current_user_id: u64,
501 message: &channel::ChannelMessage,
502 ) -> RichText {
503 let mentions = message
504 .mentions
505 .iter()
506 .map(|(range, user_id)| rich_text::Mention {
507 range: range.clone(),
508 is_self_mention: *user_id == current_user_id,
509 })
510 .collect::<Vec<_>>();
511
512 rich_text::render_markdown(message.body.clone(), &mentions, language_registry, None)
513 }
514
515 fn render_input_box(&self, theme: &Arc<Theme>, cx: &AppContext) -> AnyElement<Self> {
516 ChildView::new(&self.input_editor, cx)
517 .contained()
518 .with_style(theme.chat_panel.input_editor.container)
519 .into_any()
520 }
521
522 fn render_channel_name(
523 channel_store: &ModelHandle<ChannelStore>,
524 ix: usize,
525 item_type: ItemType,
526 is_hovered: bool,
527 workspace: WeakViewHandle<Workspace>,
528 cx: &mut ViewContext<Select>,
529 ) -> AnyElement<Select> {
530 let theme = theme::current(cx);
531 let tooltip_style = &theme.tooltip;
532 let theme = &theme.chat_panel;
533 let style = match (&item_type, is_hovered) {
534 (ItemType::Header, _) => &theme.channel_select.header,
535 (ItemType::Selected, _) => &theme.channel_select.active_item,
536 (ItemType::Unselected, false) => &theme.channel_select.item,
537 (ItemType::Unselected, true) => &theme.channel_select.hovered_item,
538 };
539
540 let channel = &channel_store.read(cx).channel_at(ix).unwrap();
541 let channel_id = channel.id;
542
543 let mut row = Flex::row()
544 .with_child(
545 Label::new("#".to_string(), style.hash.text.clone())
546 .contained()
547 .with_style(style.hash.container),
548 )
549 .with_child(Label::new(channel.name.clone(), style.name.clone()));
550
551 if matches!(item_type, ItemType::Header) {
552 row.add_children([
553 MouseEventHandler::new::<OpenChannelNotes, _>(0, cx, |mouse_state, _| {
554 render_icon_button(theme.icon_button.style_for(mouse_state), "icons/file.svg")
555 })
556 .on_click(MouseButton::Left, move |_, _, cx| {
557 if let Some(workspace) = workspace.upgrade(cx) {
558 ChannelView::open(channel_id, workspace, cx).detach();
559 }
560 })
561 .with_tooltip::<OpenChannelNotes>(
562 channel_id as usize,
563 "Open Notes",
564 Some(Box::new(OpenChannelNotes)),
565 tooltip_style.clone(),
566 cx,
567 )
568 .flex_float(),
569 MouseEventHandler::new::<ActiveCall, _>(0, cx, |mouse_state, _| {
570 render_icon_button(
571 theme.icon_button.style_for(mouse_state),
572 "icons/speaker-loud.svg",
573 )
574 })
575 .on_click(MouseButton::Left, move |_, _, cx| {
576 ActiveCall::global(cx)
577 .update(cx, |call, cx| call.join_channel(channel_id, cx))
578 .detach_and_log_err(cx);
579 })
580 .with_tooltip::<ActiveCall>(
581 channel_id as usize,
582 "Join Call",
583 Some(Box::new(JoinCall)),
584 tooltip_style.clone(),
585 cx,
586 )
587 .flex_float(),
588 ]);
589 }
590
591 row.align_children_center()
592 .contained()
593 .with_style(style.container)
594 .into_any()
595 }
596
597 fn render_sign_in_prompt(
598 &self,
599 theme: &Arc<Theme>,
600 cx: &mut ViewContext<Self>,
601 ) -> AnyElement<Self> {
602 enum SignInPromptLabel {}
603
604 MouseEventHandler::new::<SignInPromptLabel, _>(0, cx, |mouse_state, _| {
605 Label::new(
606 "Sign in to use chat".to_string(),
607 theme
608 .chat_panel
609 .sign_in_prompt
610 .style_for(mouse_state)
611 .clone(),
612 )
613 })
614 .with_cursor_style(CursorStyle::PointingHand)
615 .on_click(MouseButton::Left, move |_, this, cx| {
616 let client = this.client.clone();
617 cx.spawn(|this, mut cx| async move {
618 if client
619 .authenticate_and_connect(true, &cx)
620 .log_err()
621 .await
622 .is_some()
623 {
624 this.update(&mut cx, |this, cx| {
625 if cx.handle().is_focused(cx) {
626 cx.focus(&this.input_editor);
627 }
628 })
629 .ok();
630 }
631 })
632 .detach();
633 })
634 .aligned()
635 .into_any()
636 }
637
638 fn send(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
639 if let Some((chat, _)) = self.active_chat.as_ref() {
640 let message = self
641 .input_editor
642 .update(cx, |editor, cx| editor.take_message(cx));
643
644 if let Some(task) = chat
645 .update(cx, |chat, cx| chat.send_message(message, cx))
646 .log_err()
647 {
648 task.detach();
649 }
650 }
651 }
652
653 fn remove_message(&mut self, id: u64, cx: &mut ViewContext<Self>) {
654 if let Some((chat, _)) = self.active_chat.as_ref() {
655 chat.update(cx, |chat, cx| chat.remove_message(id, cx).detach())
656 }
657 }
658
659 fn load_more_messages(&mut self, _: &LoadMoreMessages, cx: &mut ViewContext<Self>) {
660 if let Some((chat, _)) = self.active_chat.as_ref() {
661 chat.update(cx, |channel, cx| {
662 if let Some(task) = channel.load_more_messages(cx) {
663 task.detach();
664 }
665 })
666 }
667 }
668
669 pub fn select_channel(
670 &mut self,
671 selected_channel_id: u64,
672 scroll_to_message_id: Option<u64>,
673 cx: &mut ViewContext<ChatPanel>,
674 ) -> Task<Result<()>> {
675 let open_chat = self
676 .active_chat
677 .as_ref()
678 .and_then(|(chat, _)| {
679 (chat.read(cx).channel().id == selected_channel_id)
680 .then(|| Task::ready(anyhow::Ok(chat.clone())))
681 })
682 .unwrap_or_else(|| {
683 self.channel_store.update(cx, |store, cx| {
684 store.open_channel_chat(selected_channel_id, cx)
685 })
686 });
687
688 cx.spawn(|this, mut cx| async move {
689 let chat = open_chat.await?;
690 this.update(&mut cx, |this, cx| {
691 this.set_active_chat(chat.clone(), cx);
692 })?;
693
694 if let Some(message_id) = scroll_to_message_id {
695 if let Some(item_ix) =
696 ChannelChat::load_history_since_message(chat.clone(), message_id, cx.clone())
697 .await
698 {
699 this.update(&mut cx, |this, cx| {
700 if this.active_chat.as_ref().map_or(false, |(c, _)| *c == chat) {
701 this.message_list.scroll_to(ListOffset {
702 item_ix,
703 offset_in_item: 0.,
704 });
705 cx.notify();
706 }
707 })?;
708 }
709 }
710
711 Ok(())
712 })
713 }
714
715 fn open_notes(&mut self, _: &OpenChannelNotes, cx: &mut ViewContext<Self>) {
716 if let Some((chat, _)) = &self.active_chat {
717 let channel_id = chat.read(cx).channel().id;
718 if let Some(workspace) = self.workspace.upgrade(cx) {
719 ChannelView::open(channel_id, workspace, cx).detach();
720 }
721 }
722 }
723
724 fn join_call(&mut self, _: &JoinCall, cx: &mut ViewContext<Self>) {
725 if let Some((chat, _)) = &self.active_chat {
726 let channel_id = chat.read(cx).channel().id;
727 ActiveCall::global(cx)
728 .update(cx, |call, cx| call.join_channel(channel_id, cx))
729 .detach_and_log_err(cx);
730 }
731 }
732}
733
734fn render_remove(
735 message_id_to_remove: Option<u64>,
736 cx: &mut ViewContext<'_, '_, ChatPanel>,
737 theme: &Arc<Theme>,
738) -> AnyElement<ChatPanel> {
739 enum DeleteMessage {}
740
741 message_id_to_remove
742 .map(|id| {
743 MouseEventHandler::new::<DeleteMessage, _>(id as usize, cx, |mouse_state, _| {
744 let button_style = theme.chat_panel.icon_button.style_for(mouse_state);
745 render_icon_button(button_style, "icons/x.svg")
746 .aligned()
747 .into_any()
748 })
749 .with_padding(Padding::uniform(2.))
750 .with_cursor_style(CursorStyle::PointingHand)
751 .on_click(MouseButton::Left, move |_, this, cx| {
752 this.remove_message(id, cx);
753 })
754 .flex_float()
755 .into_any()
756 })
757 .unwrap_or_else(|| {
758 let style = theme.chat_panel.icon_button.default;
759
760 Empty::new()
761 .constrained()
762 .with_width(style.icon_width)
763 .aligned()
764 .constrained()
765 .with_width(style.button_width)
766 .with_height(style.button_width)
767 .contained()
768 .with_uniform_padding(2.)
769 .flex_float()
770 .into_any()
771 })
772}
773
774impl Entity for ChatPanel {
775 type Event = Event;
776}
777
778impl View for ChatPanel {
779 fn ui_name() -> &'static str {
780 "ChatPanel"
781 }
782
783 fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
784 let theme = theme::current(cx);
785 let element = if self.client.user_id().is_some() {
786 self.render_channel(cx)
787 } else {
788 self.render_sign_in_prompt(&theme, cx)
789 };
790 element
791 .contained()
792 .with_style(theme.chat_panel.container)
793 .constrained()
794 .with_min_width(150.)
795 .into_any()
796 }
797
798 fn focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {
799 self.has_focus = true;
800 if matches!(
801 *self.client.status().borrow(),
802 client::Status::Connected { .. }
803 ) {
804 let editor = self.input_editor.read(cx).editor.clone();
805 cx.focus(&editor);
806 }
807 }
808
809 fn focus_out(&mut self, _: AnyViewHandle, _: &mut ViewContext<Self>) {
810 self.has_focus = false;
811 }
812}
813
814impl Panel for ChatPanel {
815 fn position(&self, cx: &gpui::WindowContext) -> DockPosition {
816 settings::get::<ChatPanelSettings>(cx).dock
817 }
818
819 fn position_is_valid(&self, position: DockPosition) -> bool {
820 matches!(position, DockPosition::Left | DockPosition::Right)
821 }
822
823 fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
824 settings::update_settings_file::<ChatPanelSettings>(self.fs.clone(), cx, move |settings| {
825 settings.dock = Some(position)
826 });
827 }
828
829 fn size(&self, cx: &gpui::WindowContext) -> f32 {
830 self.width
831 .unwrap_or_else(|| settings::get::<ChatPanelSettings>(cx).default_width)
832 }
833
834 fn set_size(&mut self, size: Option<f32>, cx: &mut ViewContext<Self>) {
835 self.width = size;
836 self.serialize(cx);
837 cx.notify();
838 }
839
840 fn set_active(&mut self, active: bool, cx: &mut ViewContext<Self>) {
841 self.active = active;
842 if active {
843 self.acknowledge_last_message(cx);
844 if !is_channels_feature_enabled(cx) {
845 cx.emit(Event::Dismissed);
846 }
847 }
848 }
849
850 fn icon_path(&self, cx: &gpui::WindowContext) -> Option<&'static str> {
851 (settings::get::<ChatPanelSettings>(cx).button && is_channels_feature_enabled(cx))
852 .then(|| "icons/conversations.svg")
853 }
854
855 fn icon_tooltip(&self) -> (String, Option<Box<dyn gpui::Action>>) {
856 ("Chat Panel".to_string(), Some(Box::new(ToggleFocus)))
857 }
858
859 fn should_change_position_on_event(event: &Self::Event) -> bool {
860 matches!(event, Event::DockPositionChanged)
861 }
862
863 fn should_close_on_event(event: &Self::Event) -> bool {
864 matches!(event, Event::Dismissed)
865 }
866
867 fn has_focus(&self, _cx: &gpui::WindowContext) -> bool {
868 self.has_focus
869 }
870
871 fn is_focus_event(event: &Self::Event) -> bool {
872 matches!(event, Event::Focus)
873 }
874}
875
876fn format_timestamp(
877 mut timestamp: OffsetDateTime,
878 mut now: OffsetDateTime,
879 local_timezone: UtcOffset,
880) -> String {
881 timestamp = timestamp.to_offset(local_timezone);
882 now = now.to_offset(local_timezone);
883
884 let today = now.date();
885 let date = timestamp.date();
886 let mut hour = timestamp.hour();
887 let mut part = "am";
888 if hour > 12 {
889 hour -= 12;
890 part = "pm";
891 }
892 if date == today {
893 format!("{:02}:{:02}{}", hour, timestamp.minute(), part)
894 } else if date.next_day() == Some(today) {
895 format!("yesterday at {:02}:{:02}{}", hour, timestamp.minute(), part)
896 } else {
897 format!("{:02}/{}/{}", date.month() as u32, date.day(), date.year())
898 }
899}
900
901fn render_icon_button<V: View>(style: &IconButton, svg_path: &'static str) -> impl Element<V> {
902 Svg::new(svg_path)
903 .with_color(style.color)
904 .constrained()
905 .with_width(style.icon_width)
906 .aligned()
907 .constrained()
908 .with_width(style.button_width)
909 .with_height(style.button_width)
910 .contained()
911 .with_style(style.container)
912}
913
914#[cfg(test)]
915mod tests {
916 use super::*;
917 use gpui::fonts::HighlightStyle;
918 use pretty_assertions::assert_eq;
919 use rich_text::{BackgroundKind, Highlight, RenderedRegion};
920 use util::test::marked_text_ranges;
921
922 #[gpui::test]
923 fn test_render_markdown_with_mentions() {
924 let language_registry = Arc::new(LanguageRegistry::test());
925 let (body, ranges) = marked_text_ranges("*hi*, «@abc», let's **call** «@fgh»", false);
926 let message = channel::ChannelMessage {
927 id: ChannelMessageId::Saved(0),
928 body,
929 timestamp: OffsetDateTime::now_utc(),
930 sender: Arc::new(client::User {
931 github_login: "fgh".into(),
932 avatar: None,
933 id: 103,
934 }),
935 nonce: 5,
936 mentions: vec![(ranges[0].clone(), 101), (ranges[1].clone(), 102)],
937 };
938
939 let message = ChatPanel::render_markdown_with_mentions(&language_registry, 102, &message);
940
941 // Note that the "'" was replaced with ’ due to smart punctuation.
942 let (body, ranges) = marked_text_ranges("«hi», «@abc», let’s «call» «@fgh»", false);
943 assert_eq!(message.text, body);
944 assert_eq!(
945 message.highlights,
946 vec![
947 (
948 ranges[0].clone(),
949 HighlightStyle {
950 italic: Some(true),
951 ..Default::default()
952 }
953 .into()
954 ),
955 (ranges[1].clone(), Highlight::Mention),
956 (
957 ranges[2].clone(),
958 HighlightStyle {
959 weight: Some(gpui::fonts::Weight::BOLD),
960 ..Default::default()
961 }
962 .into()
963 ),
964 (ranges[3].clone(), Highlight::SelfMention)
965 ]
966 );
967 assert_eq!(
968 message.regions,
969 vec![
970 RenderedRegion {
971 background_kind: Some(BackgroundKind::Mention),
972 link_url: None
973 },
974 RenderedRegion {
975 background_kind: Some(BackgroundKind::SelfMention),
976 link_url: None
977 },
978 ]
979 );
980 }
981}