chat_panel.rs

  1use crate::{channel_view::ChannelView, is_channels_feature_enabled, ChatPanelSettings};
  2use anyhow::Result;
  3use call::ActiveCall;
  4use channel::{ChannelChat, ChannelChatEvent, ChannelMessageId, ChannelStore};
  5use client::Client;
  6use collections::HashMap;
  7use db::kvp::KEY_VALUE_STORE;
  8use editor::Editor;
  9use gpui::{
 10    actions, div, list, prelude::*, px, serde_json, AnyElement, AppContext, AsyncWindowContext,
 11    ClickEvent, ElementId, EventEmitter, FocusableView, ListOffset, ListScrollEvent, ListState,
 12    Model, Render, Subscription, Task, View, ViewContext, VisualContext, WeakView,
 13};
 14use language::LanguageRegistry;
 15use menu::Confirm;
 16use message_editor::MessageEditor;
 17use project::Fs;
 18use rich_text::RichText;
 19use serde::{Deserialize, Serialize};
 20use settings::{Settings, SettingsStore};
 21use std::sync::Arc;
 22use theme::ActiveTheme as _;
 23use time::{OffsetDateTime, UtcOffset};
 24use ui::{prelude::*, Avatar, Button, Icon, IconButton, Label, TabBar, Tooltip};
 25use util::{ResultExt, TryFutureExt};
 26use workspace::{
 27    dock::{DockPosition, Panel, PanelEvent},
 28    Workspace,
 29};
 30
 31mod message_editor;
 32
 33const MESSAGE_LOADING_THRESHOLD: usize = 50;
 34const CHAT_PANEL_KEY: &'static str = "ChatPanel";
 35
 36pub fn init(cx: &mut AppContext) {
 37    cx.observe_new_views(|workspace: &mut Workspace, _| {
 38        workspace.register_action(|workspace, _: &ToggleFocus, cx| {
 39            workspace.toggle_panel_focus::<ChatPanel>(cx);
 40        });
 41    })
 42    .detach();
 43}
 44
 45pub struct ChatPanel {
 46    client: Arc<Client>,
 47    channel_store: Model<ChannelStore>,
 48    languages: Arc<LanguageRegistry>,
 49    message_list: ListState,
 50    active_chat: Option<(Model<ChannelChat>, Subscription)>,
 51    input_editor: View<MessageEditor>,
 52    local_timezone: UtcOffset,
 53    fs: Arc<dyn Fs>,
 54    width: Option<Pixels>,
 55    active: bool,
 56    pending_serialization: Task<Option<()>>,
 57    subscriptions: Vec<gpui::Subscription>,
 58    workspace: WeakView<Workspace>,
 59    is_scrolled_to_bottom: bool,
 60    markdown_data: HashMap<ChannelMessageId, RichText>,
 61}
 62
 63#[derive(Serialize, Deserialize)]
 64struct SerializedChatPanel {
 65    width: Option<Pixels>,
 66}
 67
 68#[derive(Debug)]
 69pub enum Event {
 70    DockPositionChanged,
 71    Focus,
 72    Dismissed,
 73}
 74
 75actions!(chat_panel, [ToggleFocus]);
 76
 77impl ChatPanel {
 78    pub fn new(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) -> View<Self> {
 79        let fs = workspace.app_state().fs.clone();
 80        let client = workspace.app_state().client.clone();
 81        let channel_store = ChannelStore::global(cx);
 82        let languages = workspace.app_state().languages.clone();
 83
 84        let input_editor = cx.new_view(|cx| {
 85            MessageEditor::new(
 86                languages.clone(),
 87                channel_store.clone(),
 88                cx.new_view(|cx| Editor::auto_height(4, cx)),
 89                cx,
 90            )
 91        });
 92
 93        let workspace_handle = workspace.weak_handle();
 94
 95        cx.new_view(|cx: &mut ViewContext<Self>| {
 96            let view = cx.view().downgrade();
 97            let message_list =
 98                ListState::new(0, gpui::ListAlignment::Bottom, px(1000.), move |ix, cx| {
 99                    if let Some(view) = view.upgrade() {
100                        view.update(cx, |view, cx| {
101                            view.render_message(ix, cx).into_any_element()
102                        })
103                    } else {
104                        div().into_any()
105                    }
106                });
107
108            message_list.set_scroll_handler(cx.listener(|this, event: &ListScrollEvent, cx| {
109                if event.visible_range.start < MESSAGE_LOADING_THRESHOLD {
110                    this.load_more_messages(cx);
111                }
112                this.is_scrolled_to_bottom = event.visible_range.end == event.count;
113            }));
114
115            let mut this = Self {
116                fs,
117                client,
118                channel_store,
119                languages,
120                message_list,
121                active_chat: Default::default(),
122                pending_serialization: Task::ready(None),
123                input_editor,
124                local_timezone: cx.local_timezone(),
125                subscriptions: Vec::new(),
126                workspace: workspace_handle,
127                is_scrolled_to_bottom: true,
128                active: false,
129                width: None,
130                markdown_data: Default::default(),
131            };
132
133            let mut old_dock_position = this.position(cx);
134            this.subscriptions.push(cx.observe_global::<SettingsStore>(
135                move |this: &mut Self, cx| {
136                    let new_dock_position = this.position(cx);
137                    if new_dock_position != old_dock_position {
138                        old_dock_position = new_dock_position;
139                        cx.emit(Event::DockPositionChanged);
140                    }
141                    cx.notify();
142                },
143            ));
144
145            this
146        })
147    }
148
149    pub fn is_scrolled_to_bottom(&self) -> bool {
150        self.is_scrolled_to_bottom
151    }
152
153    pub fn active_chat(&self) -> Option<Model<ChannelChat>> {
154        self.active_chat.as_ref().map(|(chat, _)| chat.clone())
155    }
156
157    pub fn load(
158        workspace: WeakView<Workspace>,
159        cx: AsyncWindowContext,
160    ) -> Task<Result<View<Self>>> {
161        cx.spawn(|mut cx| async move {
162            let serialized_panel = if let Some(panel) = cx
163                .background_executor()
164                .spawn(async move { KEY_VALUE_STORE.read_kvp(CHAT_PANEL_KEY) })
165                .await
166                .log_err()
167                .flatten()
168            {
169                Some(serde_json::from_str::<SerializedChatPanel>(&panel)?)
170            } else {
171                None
172            };
173
174            workspace.update(&mut cx, |workspace, cx| {
175                let panel = Self::new(workspace, cx);
176                if let Some(serialized_panel) = serialized_panel {
177                    panel.update(cx, |panel, cx| {
178                        panel.width = serialized_panel.width;
179                        cx.notify();
180                    });
181                }
182                panel
183            })
184        })
185    }
186
187    fn serialize(&mut self, cx: &mut ViewContext<Self>) {
188        let width = self.width;
189        self.pending_serialization = cx.background_executor().spawn(
190            async move {
191                KEY_VALUE_STORE
192                    .write_kvp(
193                        CHAT_PANEL_KEY.into(),
194                        serde_json::to_string(&SerializedChatPanel { width })?,
195                    )
196                    .await?;
197                anyhow::Ok(())
198            }
199            .log_err(),
200        );
201    }
202
203    fn set_active_chat(&mut self, chat: Model<ChannelChat>, cx: &mut ViewContext<Self>) {
204        if self.active_chat.as_ref().map(|e| &e.0) != Some(&chat) {
205            let channel_id = chat.read(cx).channel_id;
206            {
207                self.markdown_data.clear();
208                let chat = chat.read(cx);
209                self.message_list.reset(chat.message_count());
210
211                let channel_name = chat.channel(cx).map(|channel| channel.name.clone());
212                self.input_editor.update(cx, |editor, cx| {
213                    editor.set_channel(channel_id, channel_name, cx);
214                });
215            };
216            let subscription = cx.subscribe(&chat, Self::channel_did_change);
217            self.active_chat = Some((chat, subscription));
218            self.acknowledge_last_message(cx);
219            cx.notify();
220        }
221    }
222
223    fn channel_did_change(
224        &mut self,
225        _: Model<ChannelChat>,
226        event: &ChannelChatEvent,
227        cx: &mut ViewContext<Self>,
228    ) {
229        match event {
230            ChannelChatEvent::MessagesUpdated {
231                old_range,
232                new_count,
233            } => {
234                self.message_list.splice(old_range.clone(), *new_count);
235                if self.active {
236                    self.acknowledge_last_message(cx);
237                }
238            }
239            ChannelChatEvent::NewMessage {
240                channel_id,
241                message_id,
242            } => {
243                if !self.active {
244                    self.channel_store.update(cx, |store, cx| {
245                        store.new_message(*channel_id, *message_id, cx)
246                    })
247                }
248            }
249        }
250        cx.notify();
251    }
252
253    fn acknowledge_last_message(&mut self, cx: &mut ViewContext<Self>) {
254        if self.active && self.is_scrolled_to_bottom {
255            if let Some((chat, _)) = &self.active_chat {
256                chat.update(cx, |chat, cx| {
257                    chat.acknowledge_last_message(cx);
258                });
259            }
260        }
261    }
262
263    fn render_channel(&self, cx: &mut ViewContext<Self>) -> AnyElement {
264        v_stack()
265            .full()
266            .on_action(cx.listener(Self::send))
267            .child(
268                h_stack().z_index(1).child(
269                    TabBar::new("chat_header")
270                        .child(
271                            h_stack()
272                                .w_full()
273                                .h(rems(ui::Tab::HEIGHT_IN_REMS))
274                                .px_2()
275                                .child(Label::new(
276                                    self.active_chat
277                                        .as_ref()
278                                        .and_then(|c| {
279                                            Some(format!("#{}", c.0.read(cx).channel(cx)?.name))
280                                        })
281                                        .unwrap_or_default(),
282                                )),
283                        )
284                        .end_child(
285                            IconButton::new("notes", Icon::File)
286                                .on_click(cx.listener(Self::open_notes))
287                                .tooltip(|cx| Tooltip::text("Open notes", cx)),
288                        )
289                        .end_child(
290                            IconButton::new("call", Icon::AudioOn)
291                                .on_click(cx.listener(Self::join_call))
292                                .tooltip(|cx| Tooltip::text("Join call", cx)),
293                        ),
294                ),
295            )
296            .child(div().flex_grow().px_2().py_1().map(|this| {
297                if self.active_chat.is_some() {
298                    this.child(list(self.message_list.clone()).full())
299                } else {
300                    this
301                }
302            }))
303            .child(
304                div()
305                    .z_index(1)
306                    .p_2()
307                    .bg(cx.theme().colors().background)
308                    .child(self.input_editor.clone()),
309            )
310            .into_any()
311    }
312
313    fn render_message(&mut self, ix: usize, cx: &mut ViewContext<Self>) -> impl IntoElement {
314        let active_chat = &self.active_chat.as_ref().unwrap().0;
315        let (message, is_continuation_from_previous, is_continuation_to_next, is_admin) =
316            active_chat.update(cx, |active_chat, cx| {
317                let is_admin = self
318                    .channel_store
319                    .read(cx)
320                    .is_channel_admin(active_chat.channel_id);
321
322                let last_message = active_chat.message(ix.saturating_sub(1));
323                let this_message = active_chat.message(ix).clone();
324                let next_message =
325                    active_chat.message(ix.saturating_add(1).min(active_chat.message_count() - 1));
326
327                let is_continuation_from_previous = last_message.id != this_message.id
328                    && last_message.sender.id == this_message.sender.id;
329                let is_continuation_to_next = this_message.id != next_message.id
330                    && this_message.sender.id == next_message.sender.id;
331
332                if let ChannelMessageId::Saved(id) = this_message.id {
333                    if this_message
334                        .mentions
335                        .iter()
336                        .any(|(_, user_id)| Some(*user_id) == self.client.user_id())
337                    {
338                        active_chat.acknowledge_message(id);
339                    }
340                }
341
342                (
343                    this_message,
344                    is_continuation_from_previous,
345                    is_continuation_to_next,
346                    is_admin,
347                )
348            });
349
350        let _is_pending = message.is_pending();
351        let text = self.markdown_data.entry(message.id).or_insert_with(|| {
352            Self::render_markdown_with_mentions(&self.languages, self.client.id(), &message)
353        });
354
355        let now = OffsetDateTime::now_utc();
356
357        let belongs_to_user = Some(message.sender.id) == self.client.user_id();
358        let message_id_to_remove = if let (ChannelMessageId::Saved(id), true) =
359            (message.id, belongs_to_user || is_admin)
360        {
361            Some(id)
362        } else {
363            None
364        };
365
366        let element_id: ElementId = match message.id {
367            ChannelMessageId::Saved(id) => ("saved-message", id).into(),
368            ChannelMessageId::Pending(id) => ("pending-message", id).into(),
369        };
370
371        v_stack()
372            .w_full()
373            .id(element_id)
374            .relative()
375            .overflow_hidden()
376            .group("")
377            .when(!is_continuation_from_previous, |this| {
378                this.child(
379                    h_stack()
380                        .gap_2()
381                        .child(Avatar::new(message.sender.avatar_uri.clone()))
382                        .child(Label::new(message.sender.github_login.clone()))
383                        .child(
384                            Label::new(format_timestamp(
385                                message.timestamp,
386                                now,
387                                self.local_timezone,
388                            ))
389                            .color(Color::Muted),
390                        ),
391                )
392            })
393            .when(!is_continuation_to_next, |this|
394                // HACK: This should really be a margin, but margins seem to get collapsed.
395                this.pb_2())
396            .child(text.element("body".into(), cx))
397            .child(
398                div()
399                    .absolute()
400                    .top_1()
401                    .right_2()
402                    .w_8()
403                    .visible_on_hover("")
404                    .children(message_id_to_remove.map(|message_id| {
405                        IconButton::new(("remove", message_id), Icon::XCircle).on_click(
406                            cx.listener(move |this, _, cx| {
407                                this.remove_message(message_id, cx);
408                            }),
409                        )
410                    })),
411            )
412    }
413
414    fn render_markdown_with_mentions(
415        language_registry: &Arc<LanguageRegistry>,
416        current_user_id: u64,
417        message: &channel::ChannelMessage,
418    ) -> RichText {
419        let mentions = message
420            .mentions
421            .iter()
422            .map(|(range, user_id)| rich_text::Mention {
423                range: range.clone(),
424                is_self_mention: *user_id == current_user_id,
425            })
426            .collect::<Vec<_>>();
427
428        rich_text::render_markdown(message.body.clone(), &mentions, language_registry, None)
429    }
430
431    fn render_sign_in_prompt(&self, cx: &mut ViewContext<Self>) -> AnyElement {
432        Button::new("sign-in", "Sign in to use chat")
433            .on_click(cx.listener(move |this, _, cx| {
434                let client = this.client.clone();
435                cx.spawn(|this, mut cx| async move {
436                    if client
437                        .authenticate_and_connect(true, &cx)
438                        .log_err()
439                        .await
440                        .is_some()
441                    {
442                        this.update(&mut cx, |_, cx| {
443                            cx.focus_self();
444                        })
445                        .ok();
446                    }
447                })
448                .detach();
449            }))
450            .into_any_element()
451    }
452
453    fn send(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
454        if let Some((chat, _)) = self.active_chat.as_ref() {
455            let message = self
456                .input_editor
457                .update(cx, |editor, cx| editor.take_message(cx));
458
459            if let Some(task) = chat
460                .update(cx, |chat, cx| chat.send_message(message, cx))
461                .log_err()
462            {
463                task.detach();
464            }
465        }
466    }
467
468    fn remove_message(&mut self, id: u64, cx: &mut ViewContext<Self>) {
469        if let Some((chat, _)) = self.active_chat.as_ref() {
470            chat.update(cx, |chat, cx| chat.remove_message(id, cx).detach())
471        }
472    }
473
474    fn load_more_messages(&mut self, cx: &mut ViewContext<Self>) {
475        if let Some((chat, _)) = self.active_chat.as_ref() {
476            chat.update(cx, |channel, cx| {
477                if let Some(task) = channel.load_more_messages(cx) {
478                    task.detach();
479                }
480            })
481        }
482    }
483
484    pub fn select_channel(
485        &mut self,
486        selected_channel_id: u64,
487        scroll_to_message_id: Option<u64>,
488        cx: &mut ViewContext<ChatPanel>,
489    ) -> Task<Result<()>> {
490        let open_chat = self
491            .active_chat
492            .as_ref()
493            .and_then(|(chat, _)| {
494                (chat.read(cx).channel_id == selected_channel_id)
495                    .then(|| Task::ready(anyhow::Ok(chat.clone())))
496            })
497            .unwrap_or_else(|| {
498                self.channel_store.update(cx, |store, cx| {
499                    store.open_channel_chat(selected_channel_id, cx)
500                })
501            });
502
503        cx.spawn(|this, mut cx| async move {
504            let chat = open_chat.await?;
505            this.update(&mut cx, |this, cx| {
506                this.set_active_chat(chat.clone(), cx);
507            })?;
508
509            if let Some(message_id) = scroll_to_message_id {
510                if let Some(item_ix) =
511                    ChannelChat::load_history_since_message(chat.clone(), message_id, (*cx).clone())
512                        .await
513                {
514                    this.update(&mut cx, |this, cx| {
515                        if this.active_chat.as_ref().map_or(false, |(c, _)| *c == chat) {
516                            this.message_list.scroll_to(ListOffset {
517                                item_ix,
518                                offset_in_item: px(0.0),
519                            });
520                            cx.notify();
521                        }
522                    })?;
523                }
524            }
525
526            Ok(())
527        })
528    }
529
530    fn open_notes(&mut self, _: &ClickEvent, cx: &mut ViewContext<Self>) {
531        if let Some((chat, _)) = &self.active_chat {
532            let channel_id = chat.read(cx).channel_id;
533            if let Some(workspace) = self.workspace.upgrade() {
534                ChannelView::open(channel_id, workspace, cx).detach();
535            }
536        }
537    }
538
539    fn join_call(&mut self, _: &ClickEvent, cx: &mut ViewContext<Self>) {
540        if let Some((chat, _)) = &self.active_chat {
541            let channel_id = chat.read(cx).channel_id;
542            ActiveCall::global(cx)
543                .update(cx, |call, cx| call.join_channel(channel_id, cx))
544                .detach_and_log_err(cx);
545        }
546    }
547}
548
549impl EventEmitter<Event> for ChatPanel {}
550
551impl Render for ChatPanel {
552    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
553        div()
554            .full()
555            .child(if self.client.user_id().is_some() {
556                self.render_channel(cx)
557            } else {
558                self.render_sign_in_prompt(cx)
559            })
560            .min_w(px(150.))
561    }
562}
563
564impl FocusableView for ChatPanel {
565    fn focus_handle(&self, cx: &AppContext) -> gpui::FocusHandle {
566        self.input_editor.read(cx).focus_handle(cx)
567    }
568}
569
570impl Panel for ChatPanel {
571    fn position(&self, cx: &gpui::WindowContext) -> DockPosition {
572        ChatPanelSettings::get_global(cx).dock
573    }
574
575    fn position_is_valid(&self, position: DockPosition) -> bool {
576        matches!(position, DockPosition::Left | DockPosition::Right)
577    }
578
579    fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
580        settings::update_settings_file::<ChatPanelSettings>(self.fs.clone(), cx, move |settings| {
581            settings.dock = Some(position)
582        });
583    }
584
585    fn size(&self, cx: &gpui::WindowContext) -> Pixels {
586        self.width
587            .unwrap_or_else(|| ChatPanelSettings::get_global(cx).default_width)
588    }
589
590    fn set_size(&mut self, size: Option<Pixels>, cx: &mut ViewContext<Self>) {
591        self.width = size;
592        self.serialize(cx);
593        cx.notify();
594    }
595
596    fn set_active(&mut self, active: bool, cx: &mut ViewContext<Self>) {
597        self.active = active;
598        if active {
599            self.acknowledge_last_message(cx);
600            if !is_channels_feature_enabled(cx) {
601                cx.emit(Event::Dismissed);
602            }
603        }
604    }
605
606    fn persistent_name() -> &'static str {
607        "ChatPanel"
608    }
609
610    fn icon(&self, _cx: &WindowContext) -> Option<ui::Icon> {
611        Some(ui::Icon::MessageBubbles)
612    }
613
614    fn icon_tooltip(&self, _cx: &WindowContext) -> Option<&'static str> {
615        Some("Chat Panel")
616    }
617
618    fn toggle_action(&self) -> Box<dyn gpui::Action> {
619        Box::new(ToggleFocus)
620    }
621}
622
623impl EventEmitter<PanelEvent> for ChatPanel {}
624
625fn format_timestamp(
626    mut timestamp: OffsetDateTime,
627    mut now: OffsetDateTime,
628    local_timezone: UtcOffset,
629) -> String {
630    timestamp = timestamp.to_offset(local_timezone);
631    now = now.to_offset(local_timezone);
632
633    let today = now.date();
634    let date = timestamp.date();
635    let mut hour = timestamp.hour();
636    let mut part = "am";
637    if hour > 12 {
638        hour -= 12;
639        part = "pm";
640    }
641    if date == today {
642        format!("{:02}:{:02}{}", hour, timestamp.minute(), part)
643    } else if date.next_day() == Some(today) {
644        format!("yesterday at {:02}:{:02}{}", hour, timestamp.minute(), part)
645    } else {
646        format!("{:02}/{}/{}", date.month() as u32, date.day(), date.year())
647    }
648}
649
650#[cfg(test)]
651mod tests {
652    use super::*;
653    use gpui::HighlightStyle;
654    use pretty_assertions::assert_eq;
655    use rich_text::Highlight;
656    use util::test::marked_text_ranges;
657
658    #[gpui::test]
659    fn test_render_markdown_with_mentions() {
660        let language_registry = Arc::new(LanguageRegistry::test());
661        let (body, ranges) = marked_text_ranges("*hi*, «@abc», let's **call** «@fgh»", false);
662        let message = channel::ChannelMessage {
663            id: ChannelMessageId::Saved(0),
664            body,
665            timestamp: OffsetDateTime::now_utc(),
666            sender: Arc::new(client::User {
667                github_login: "fgh".into(),
668                avatar_uri: "avatar_fgh".into(),
669                id: 103,
670            }),
671            nonce: 5,
672            mentions: vec![(ranges[0].clone(), 101), (ranges[1].clone(), 102)],
673        };
674
675        let message = ChatPanel::render_markdown_with_mentions(&language_registry, 102, &message);
676
677        // Note that the "'" was replaced with ’ due to smart punctuation.
678        let (body, ranges) = marked_text_ranges("«hi», «@abc», let’s «call» «@fgh»", false);
679        assert_eq!(message.text, body);
680        assert_eq!(
681            message.highlights,
682            vec![
683                (
684                    ranges[0].clone(),
685                    HighlightStyle {
686                        font_style: Some(gpui::FontStyle::Italic),
687                        ..Default::default()
688                    }
689                    .into()
690                ),
691                (ranges[1].clone(), Highlight::Mention),
692                (
693                    ranges[2].clone(),
694                    HighlightStyle {
695                        font_weight: Some(gpui::FontWeight::BOLD),
696                        ..Default::default()
697                    }
698                    .into()
699                ),
700                (ranges[3].clone(), Highlight::SelfMention)
701            ]
702        );
703    }
704}