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