chat_panel.rs

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