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