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