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            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}