chat_panel.rs

  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}