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 let channel_id = chat.read(cx).channel_id;
267 {
268 self.markdown_data.clear();
269 let chat = chat.read(cx);
270 self.message_list.reset(chat.message_count());
271
272 let channel_name = chat.channel(cx).map(|channel| channel.name.clone());
273 self.input_editor.update(cx, |editor, cx| {
274 editor.set_channel(channel_id, channel_name, cx);
275 });
276 };
277 let subscription = cx.subscribe(&chat, Self::channel_did_change);
278 self.active_chat = Some((chat, subscription));
279 self.acknowledge_last_message(cx);
280 self.channel_select.update(cx, |select, cx| {
281 if let Some(ix) = self.channel_store.read(cx).index_of_channel(channel_id) {
282 select.set_selected_index(ix, cx);
283 }
284 });
285 cx.notify();
286 }
287 }
288
289 fn channel_did_change(
290 &mut self,
291 _: ModelHandle<ChannelChat>,
292 event: &ChannelChatEvent,
293 cx: &mut ViewContext<Self>,
294 ) {
295 match event {
296 ChannelChatEvent::MessagesUpdated {
297 old_range,
298 new_count,
299 } => {
300 self.message_list.splice(old_range.clone(), *new_count);
301 if self.active {
302 self.acknowledge_last_message(cx);
303 }
304 }
305 ChannelChatEvent::NewMessage {
306 channel_id,
307 message_id,
308 } => {
309 if !self.active {
310 self.channel_store.update(cx, |store, cx| {
311 store.new_message(*channel_id, *message_id, cx)
312 })
313 }
314 }
315 }
316 cx.notify();
317 }
318
319 fn acknowledge_last_message(&mut self, cx: &mut ViewContext<'_, '_, ChatPanel>) {
320 if self.active && self.is_scrolled_to_bottom {
321 if let Some((chat, _)) = &self.active_chat {
322 chat.update(cx, |chat, cx| {
323 chat.acknowledge_last_message(cx);
324 });
325 }
326 }
327 }
328
329 fn render_channel(&self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
330 let theme = theme::current(cx);
331 Flex::column()
332 .with_child(
333 ChildView::new(&self.channel_select, cx)
334 .contained()
335 .with_style(theme.chat_panel.channel_select.container),
336 )
337 .with_child(self.render_active_channel_messages(&theme))
338 .with_child(self.render_input_box(&theme, cx))
339 .into_any()
340 }
341
342 fn render_active_channel_messages(&self, theme: &Arc<Theme>) -> AnyElement<Self> {
343 let messages = if self.active_chat.is_some() {
344 List::new(self.message_list.clone())
345 .contained()
346 .with_style(theme.chat_panel.list)
347 .into_any()
348 } else {
349 Empty::new().into_any()
350 };
351
352 messages.flex(1., true).into_any()
353 }
354
355 fn render_message(&mut self, ix: usize, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
356 let (message, is_continuation, is_last, is_admin) = self
357 .active_chat
358 .as_ref()
359 .unwrap()
360 .0
361 .update(cx, |active_chat, cx| {
362 let is_admin = self
363 .channel_store
364 .read(cx)
365 .is_channel_admin(active_chat.channel_id);
366
367 let last_message = active_chat.message(ix.saturating_sub(1));
368 let this_message = active_chat.message(ix).clone();
369 let is_continuation = last_message.id != this_message.id
370 && this_message.sender.id == last_message.sender.id;
371
372 if let ChannelMessageId::Saved(id) = this_message.id {
373 if this_message
374 .mentions
375 .iter()
376 .any(|(_, user_id)| Some(*user_id) == self.client.user_id())
377 {
378 active_chat.acknowledge_message(id);
379 }
380 }
381
382 (
383 this_message,
384 is_continuation,
385 active_chat.message_count() == ix + 1,
386 is_admin,
387 )
388 });
389
390 let is_pending = message.is_pending();
391 let theme = theme::current(cx);
392 let text = self.markdown_data.entry(message.id).or_insert_with(|| {
393 Self::render_markdown_with_mentions(&self.languages, self.client.id(), &message)
394 });
395
396 let now = OffsetDateTime::now_utc();
397
398 let style = if is_pending {
399 &theme.chat_panel.pending_message
400 } else if is_continuation {
401 &theme.chat_panel.continuation_message
402 } else {
403 &theme.chat_panel.message
404 };
405
406 let belongs_to_user = Some(message.sender.id) == self.client.user_id();
407 let message_id_to_remove = if let (ChannelMessageId::Saved(id), true) =
408 (message.id, belongs_to_user || is_admin)
409 {
410 Some(id)
411 } else {
412 None
413 };
414
415 enum MessageBackgroundHighlight {}
416 MouseEventHandler::new::<MessageBackgroundHighlight, _>(ix, cx, |state, cx| {
417 let container = style.style_for(state);
418 if is_continuation {
419 Flex::row()
420 .with_child(
421 text.element(
422 theme.editor.syntax.clone(),
423 theme.chat_panel.rich_text.clone(),
424 cx,
425 )
426 .flex(1., true),
427 )
428 .with_child(render_remove(message_id_to_remove, cx, &theme))
429 .contained()
430 .with_style(*container)
431 .with_margin_bottom(if is_last {
432 theme.chat_panel.last_message_bottom_spacing
433 } else {
434 0.
435 })
436 .into_any()
437 } else {
438 Flex::column()
439 .with_child(
440 Flex::row()
441 .with_child(
442 Flex::row()
443 .with_child(render_avatar(
444 message.sender.avatar.clone(),
445 &theme.chat_panel.avatar,
446 theme.chat_panel.avatar_container,
447 ))
448 .with_child(
449 Label::new(
450 message.sender.github_login.clone(),
451 theme.chat_panel.message_sender.text.clone(),
452 )
453 .contained()
454 .with_style(theme.chat_panel.message_sender.container),
455 )
456 .with_child(
457 Label::new(
458 format_timestamp(
459 message.timestamp,
460 now,
461 self.local_timezone,
462 ),
463 theme.chat_panel.message_timestamp.text.clone(),
464 )
465 .contained()
466 .with_style(theme.chat_panel.message_timestamp.container),
467 )
468 .align_children_center()
469 .flex(1., true),
470 )
471 .with_child(render_remove(message_id_to_remove, cx, &theme))
472 .align_children_center(),
473 )
474 .with_child(
475 Flex::row()
476 .with_child(
477 text.element(
478 theme.editor.syntax.clone(),
479 theme.chat_panel.rich_text.clone(),
480 cx,
481 )
482 .flex(1., true),
483 )
484 // Add a spacer to make everything line up
485 .with_child(render_remove(None, cx, &theme)),
486 )
487 .contained()
488 .with_style(*container)
489 .with_margin_bottom(if is_last {
490 theme.chat_panel.last_message_bottom_spacing
491 } else {
492 0.
493 })
494 .into_any()
495 }
496 })
497 .into_any()
498 }
499
500 fn render_markdown_with_mentions(
501 language_registry: &Arc<LanguageRegistry>,
502 current_user_id: u64,
503 message: &channel::ChannelMessage,
504 ) -> RichText {
505 let mentions = message
506 .mentions
507 .iter()
508 .map(|(range, user_id)| rich_text::Mention {
509 range: range.clone(),
510 is_self_mention: *user_id == current_user_id,
511 })
512 .collect::<Vec<_>>();
513
514 rich_text::render_markdown(message.body.clone(), &mentions, language_registry, None)
515 }
516
517 fn render_input_box(&self, theme: &Arc<Theme>, cx: &AppContext) -> AnyElement<Self> {
518 ChildView::new(&self.input_editor, cx)
519 .contained()
520 .with_style(theme.chat_panel.input_editor.container)
521 .into_any()
522 }
523
524 fn render_channel_name(
525 channel_store: &ModelHandle<ChannelStore>,
526 ix: usize,
527 item_type: ItemType,
528 is_hovered: bool,
529 workspace: WeakViewHandle<Workspace>,
530 cx: &mut ViewContext<Select>,
531 ) -> AnyElement<Select> {
532 let theme = theme::current(cx);
533 let tooltip_style = &theme.tooltip;
534 let theme = &theme.chat_panel;
535 let style = match (&item_type, is_hovered) {
536 (ItemType::Header, _) => &theme.channel_select.header,
537 (ItemType::Selected, _) => &theme.channel_select.active_item,
538 (ItemType::Unselected, false) => &theme.channel_select.item,
539 (ItemType::Unselected, true) => &theme.channel_select.hovered_item,
540 };
541
542 let channel = &channel_store.read(cx).channel_at(ix).unwrap();
543 let channel_id = channel.id;
544
545 let mut row = Flex::row()
546 .with_child(
547 Label::new("#".to_string(), style.hash.text.clone())
548 .contained()
549 .with_style(style.hash.container),
550 )
551 .with_child(Label::new(channel.name.clone(), style.name.clone()));
552
553 if matches!(item_type, ItemType::Header) {
554 row.add_children([
555 MouseEventHandler::new::<OpenChannelNotes, _>(0, cx, |mouse_state, _| {
556 render_icon_button(theme.icon_button.style_for(mouse_state), "icons/file.svg")
557 })
558 .on_click(MouseButton::Left, move |_, _, cx| {
559 if let Some(workspace) = workspace.upgrade(cx) {
560 ChannelView::open(channel_id, workspace, cx).detach();
561 }
562 })
563 .with_tooltip::<OpenChannelNotes>(
564 channel_id as usize,
565 "Open Notes",
566 Some(Box::new(OpenChannelNotes)),
567 tooltip_style.clone(),
568 cx,
569 )
570 .flex_float(),
571 MouseEventHandler::new::<ActiveCall, _>(0, cx, |mouse_state, _| {
572 render_icon_button(
573 theme.icon_button.style_for(mouse_state),
574 "icons/speaker-loud.svg",
575 )
576 })
577 .on_click(MouseButton::Left, move |_, _, cx| {
578 ActiveCall::global(cx)
579 .update(cx, |call, cx| call.join_channel(channel_id, cx))
580 .detach_and_log_err(cx);
581 })
582 .with_tooltip::<ActiveCall>(
583 channel_id as usize,
584 "Join Call",
585 Some(Box::new(JoinCall)),
586 tooltip_style.clone(),
587 cx,
588 )
589 .flex_float(),
590 ]);
591 }
592
593 row.align_children_center()
594 .contained()
595 .with_style(style.container)
596 .into_any()
597 }
598
599 fn render_sign_in_prompt(
600 &self,
601 theme: &Arc<Theme>,
602 cx: &mut ViewContext<Self>,
603 ) -> AnyElement<Self> {
604 enum SignInPromptLabel {}
605
606 MouseEventHandler::new::<SignInPromptLabel, _>(0, cx, |mouse_state, _| {
607 Label::new(
608 "Sign in to use chat".to_string(),
609 theme
610 .chat_panel
611 .sign_in_prompt
612 .style_for(mouse_state)
613 .clone(),
614 )
615 })
616 .with_cursor_style(CursorStyle::PointingHand)
617 .on_click(MouseButton::Left, move |_, this, cx| {
618 let client = this.client.clone();
619 cx.spawn(|this, mut cx| async move {
620 if client
621 .authenticate_and_connect(true, &cx)
622 .log_err()
623 .await
624 .is_some()
625 {
626 this.update(&mut cx, |this, cx| {
627 if cx.handle().is_focused(cx) {
628 cx.focus(&this.input_editor);
629 }
630 })
631 .ok();
632 }
633 })
634 .detach();
635 })
636 .aligned()
637 .into_any()
638 }
639
640 fn send(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
641 if let Some((chat, _)) = self.active_chat.as_ref() {
642 let message = self
643 .input_editor
644 .update(cx, |editor, cx| editor.take_message(cx));
645
646 if let Some(task) = chat
647 .update(cx, |chat, cx| chat.send_message(message, cx))
648 .log_err()
649 {
650 task.detach();
651 }
652 }
653 }
654
655 fn remove_message(&mut self, id: u64, cx: &mut ViewContext<Self>) {
656 if let Some((chat, _)) = self.active_chat.as_ref() {
657 chat.update(cx, |chat, cx| chat.remove_message(id, cx).detach())
658 }
659 }
660
661 fn load_more_messages(&mut self, _: &LoadMoreMessages, cx: &mut ViewContext<Self>) {
662 if let Some((chat, _)) = self.active_chat.as_ref() {
663 chat.update(cx, |channel, cx| {
664 if let Some(task) = channel.load_more_messages(cx) {
665 task.detach();
666 }
667 })
668 }
669 }
670
671 pub fn select_channel(
672 &mut self,
673 selected_channel_id: u64,
674 scroll_to_message_id: Option<u64>,
675 cx: &mut ViewContext<ChatPanel>,
676 ) -> Task<Result<()>> {
677 let open_chat = self
678 .active_chat
679 .as_ref()
680 .and_then(|(chat, _)| {
681 (chat.read(cx).channel_id == selected_channel_id)
682 .then(|| Task::ready(anyhow::Ok(chat.clone())))
683 })
684 .unwrap_or_else(|| {
685 self.channel_store.update(cx, |store, cx| {
686 store.open_channel_chat(selected_channel_id, cx)
687 })
688 });
689
690 cx.spawn(|this, mut cx| async move {
691 let chat = open_chat.await?;
692 this.update(&mut cx, |this, cx| {
693 this.set_active_chat(chat.clone(), cx);
694 })?;
695
696 if let Some(message_id) = scroll_to_message_id {
697 if let Some(item_ix) =
698 ChannelChat::load_history_since_message(chat.clone(), message_id, cx.clone())
699 .await
700 {
701 this.update(&mut cx, |this, cx| {
702 if this.active_chat.as_ref().map_or(false, |(c, _)| *c == chat) {
703 this.message_list.scroll_to(ListOffset {
704 item_ix,
705 offset_in_item: 0.,
706 });
707 cx.notify();
708 }
709 })?;
710 }
711 }
712
713 Ok(())
714 })
715 }
716
717 fn open_notes(&mut self, _: &OpenChannelNotes, cx: &mut ViewContext<Self>) {
718 if let Some((chat, _)) = &self.active_chat {
719 let channel_id = chat.read(cx).channel_id;
720 if let Some(workspace) = self.workspace.upgrade(cx) {
721 ChannelView::open(channel_id, workspace, cx).detach();
722 }
723 }
724 }
725
726 fn join_call(&mut self, _: &JoinCall, cx: &mut ViewContext<Self>) {
727 if let Some((chat, _)) = &self.active_chat {
728 let channel_id = chat.read(cx).channel_id;
729 ActiveCall::global(cx)
730 .update(cx, |call, cx| call.join_channel(channel_id, cx))
731 .detach_and_log_err(cx);
732 }
733 }
734}
735
736fn render_remove(
737 message_id_to_remove: Option<u64>,
738 cx: &mut ViewContext<'_, '_, ChatPanel>,
739 theme: &Arc<Theme>,
740) -> AnyElement<ChatPanel> {
741 enum DeleteMessage {}
742
743 message_id_to_remove
744 .map(|id| {
745 MouseEventHandler::new::<DeleteMessage, _>(id as usize, cx, |mouse_state, _| {
746 let button_style = theme.chat_panel.icon_button.style_for(mouse_state);
747 render_icon_button(button_style, "icons/x.svg")
748 .aligned()
749 .into_any()
750 })
751 .with_padding(Padding::uniform(2.))
752 .with_cursor_style(CursorStyle::PointingHand)
753 .on_click(MouseButton::Left, move |_, this, cx| {
754 this.remove_message(id, cx);
755 })
756 .flex_float()
757 .into_any()
758 })
759 .unwrap_or_else(|| {
760 let style = theme.chat_panel.icon_button.default;
761
762 Empty::new()
763 .constrained()
764 .with_width(style.icon_width)
765 .aligned()
766 .constrained()
767 .with_width(style.button_width)
768 .with_height(style.button_width)
769 .contained()
770 .with_uniform_padding(2.)
771 .flex_float()
772 .into_any()
773 })
774}
775
776impl Entity for ChatPanel {
777 type Event = Event;
778}
779
780impl View for ChatPanel {
781 fn ui_name() -> &'static str {
782 "ChatPanel"
783 }
784
785 fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
786 let theme = theme::current(cx);
787 let element = if self.client.user_id().is_some() {
788 self.render_channel(cx)
789 } else {
790 self.render_sign_in_prompt(&theme, cx)
791 };
792 element
793 .contained()
794 .with_style(theme.chat_panel.container)
795 .constrained()
796 .with_min_width(150.)
797 .into_any()
798 }
799
800 fn focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {
801 self.has_focus = true;
802 if matches!(
803 *self.client.status().borrow(),
804 client::Status::Connected { .. }
805 ) {
806 let editor = self.input_editor.read(cx).editor.clone();
807 cx.focus(&editor);
808 }
809 }
810
811 fn focus_out(&mut self, _: AnyViewHandle, _: &mut ViewContext<Self>) {
812 self.has_focus = false;
813 }
814}
815
816impl Panel for ChatPanel {
817 fn position(&self, cx: &gpui::WindowContext) -> DockPosition {
818 settings::get::<ChatPanelSettings>(cx).dock
819 }
820
821 fn position_is_valid(&self, position: DockPosition) -> bool {
822 matches!(position, DockPosition::Left | DockPosition::Right)
823 }
824
825 fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
826 settings::update_settings_file::<ChatPanelSettings>(self.fs.clone(), cx, move |settings| {
827 settings.dock = Some(position)
828 });
829 }
830
831 fn size(&self, cx: &gpui::WindowContext) -> f32 {
832 self.width
833 .unwrap_or_else(|| settings::get::<ChatPanelSettings>(cx).default_width)
834 }
835
836 fn set_size(&mut self, size: Option<f32>, cx: &mut ViewContext<Self>) {
837 self.width = size;
838 self.serialize(cx);
839 cx.notify();
840 }
841
842 fn set_active(&mut self, active: bool, cx: &mut ViewContext<Self>) {
843 self.active = active;
844 if active {
845 self.acknowledge_last_message(cx);
846 if !is_channels_feature_enabled(cx) {
847 cx.emit(Event::Dismissed);
848 }
849 }
850 }
851
852 fn icon_path(&self, cx: &gpui::WindowContext) -> Option<&'static str> {
853 (settings::get::<ChatPanelSettings>(cx).button && is_channels_feature_enabled(cx))
854 .then(|| "icons/conversations.svg")
855 }
856
857 fn icon_tooltip(&self) -> (String, Option<Box<dyn gpui::Action>>) {
858 ("Chat Panel".to_string(), Some(Box::new(ToggleFocus)))
859 }
860
861 fn should_change_position_on_event(event: &Self::Event) -> bool {
862 matches!(event, Event::DockPositionChanged)
863 }
864
865 fn should_close_on_event(event: &Self::Event) -> bool {
866 matches!(event, Event::Dismissed)
867 }
868
869 fn has_focus(&self, _cx: &gpui::WindowContext) -> bool {
870 self.has_focus
871 }
872
873 fn is_focus_event(event: &Self::Event) -> bool {
874 matches!(event, Event::Focus)
875 }
876}
877
878fn format_timestamp(
879 mut timestamp: OffsetDateTime,
880 mut now: OffsetDateTime,
881 local_timezone: UtcOffset,
882) -> String {
883 timestamp = timestamp.to_offset(local_timezone);
884 now = now.to_offset(local_timezone);
885
886 let today = now.date();
887 let date = timestamp.date();
888 let mut hour = timestamp.hour();
889 let mut part = "am";
890 if hour > 12 {
891 hour -= 12;
892 part = "pm";
893 }
894 if date == today {
895 format!("{:02}:{:02}{}", hour, timestamp.minute(), part)
896 } else if date.next_day() == Some(today) {
897 format!("yesterday at {:02}:{:02}{}", hour, timestamp.minute(), part)
898 } else {
899 format!("{:02}/{}/{}", date.month() as u32, date.day(), date.year())
900 }
901}
902
903fn render_icon_button<V: View>(style: &IconButton, svg_path: &'static str) -> impl Element<V> {
904 Svg::new(svg_path)
905 .with_color(style.color)
906 .constrained()
907 .with_width(style.icon_width)
908 .aligned()
909 .constrained()
910 .with_width(style.button_width)
911 .with_height(style.button_width)
912 .contained()
913 .with_style(style.container)
914}
915
916#[cfg(test)]
917mod tests {
918 use super::*;
919 use gpui::fonts::HighlightStyle;
920 use pretty_assertions::assert_eq;
921 use rich_text::{BackgroundKind, Highlight, RenderedRegion};
922 use util::test::marked_text_ranges;
923
924 #[gpui::test]
925 fn test_render_markdown_with_mentions() {
926 let language_registry = Arc::new(LanguageRegistry::test());
927 let (body, ranges) = marked_text_ranges("*hi*, «@abc», let's **call** «@fgh»", false);
928 let message = channel::ChannelMessage {
929 id: ChannelMessageId::Saved(0),
930 body,
931 timestamp: OffsetDateTime::now_utc(),
932 sender: Arc::new(client::User {
933 github_login: "fgh".into(),
934 avatar: None,
935 id: 103,
936 }),
937 nonce: 5,
938 mentions: vec![(ranges[0].clone(), 101), (ranges[1].clone(), 102)],
939 };
940
941 let message = ChatPanel::render_markdown_with_mentions(&language_registry, 102, &message);
942
943 // Note that the "'" was replaced with ’ due to smart punctuation.
944 let (body, ranges) = marked_text_ranges("«hi», «@abc», let’s «call» «@fgh»", false);
945 assert_eq!(message.text, body);
946 assert_eq!(
947 message.highlights,
948 vec![
949 (
950 ranges[0].clone(),
951 HighlightStyle {
952 italic: Some(true),
953 ..Default::default()
954 }
955 .into()
956 ),
957 (ranges[1].clone(), Highlight::Mention),
958 (
959 ranges[2].clone(),
960 HighlightStyle {
961 weight: Some(gpui::fonts::Weight::BOLD),
962 ..Default::default()
963 }
964 .into()
965 ),
966 (ranges[3].clone(), Highlight::SelfMention)
967 ]
968 );
969 assert_eq!(
970 message.regions,
971 vec![
972 RenderedRegion {
973 background_kind: Some(BackgroundKind::Mention),
974 link_url: None
975 },
976 RenderedRegion {
977 background_kind: Some(BackgroundKind::SelfMention),
978 link_url: None
979 },
980 ]
981 );
982 }
983}