chat_panel.rs

  1use crate::ChatPanelSettings;
  2use anyhow::Result;
  3use channel::{ChannelChat, ChannelChatEvent, ChannelMessage, ChannelStore};
  4use client::Client;
  5use db::kvp::KEY_VALUE_STORE;
  6use editor::Editor;
  7use gpui::{
  8    actions,
  9    elements::*,
 10    platform::{CursorStyle, MouseButton},
 11    serde_json,
 12    views::{ItemType, Select, SelectStyle},
 13    AnyViewHandle, AppContext, AsyncAppContext, Entity, ModelHandle, Subscription, Task, View,
 14    ViewContext, ViewHandle, WeakViewHandle,
 15};
 16use language::language_settings::SoftWrap;
 17use menu::Confirm;
 18use project::Fs;
 19use serde::{Deserialize, Serialize};
 20use settings::SettingsStore;
 21use std::sync::Arc;
 22use theme::Theme;
 23use time::{OffsetDateTime, UtcOffset};
 24use util::{ResultExt, TryFutureExt};
 25use workspace::{
 26    dock::{DockPosition, Panel},
 27    Workspace,
 28};
 29
 30const MESSAGE_LOADING_THRESHOLD: usize = 50;
 31const CHAT_PANEL_KEY: &'static str = "ChatPanel";
 32
 33pub struct ChatPanel {
 34    client: Arc<Client>,
 35    channel_store: ModelHandle<ChannelStore>,
 36    active_chat: Option<(ModelHandle<ChannelChat>, Subscription)>,
 37    message_list: ListState<ChatPanel>,
 38    input_editor: ViewHandle<Editor>,
 39    channel_select: ViewHandle<Select>,
 40    local_timezone: UtcOffset,
 41    fs: Arc<dyn Fs>,
 42    width: Option<f32>,
 43    pending_serialization: Task<Option<()>>,
 44    subscriptions: Vec<gpui::Subscription>,
 45    has_focus: bool,
 46}
 47
 48#[derive(Serialize, Deserialize)]
 49struct SerializedChatPanel {
 50    width: Option<f32>,
 51}
 52
 53#[derive(Debug)]
 54pub enum Event {
 55    DockPositionChanged,
 56    Focus,
 57    Dismissed,
 58}
 59
 60actions!(chat_panel, [LoadMoreMessages, ToggleFocus]);
 61
 62pub fn init(cx: &mut AppContext) {
 63    cx.add_action(ChatPanel::send);
 64    cx.add_action(ChatPanel::load_more_messages);
 65}
 66
 67impl ChatPanel {
 68    pub fn new(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) -> ViewHandle<Self> {
 69        let fs = workspace.app_state().fs.clone();
 70        let client = workspace.app_state().client.clone();
 71        let channel_store = workspace.app_state().channel_store.clone();
 72
 73        let input_editor = cx.add_view(|cx| {
 74            let mut editor = Editor::auto_height(
 75                4,
 76                Some(Arc::new(|theme| theme.chat_panel.input_editor.clone())),
 77                cx,
 78            );
 79            editor.set_soft_wrap_mode(SoftWrap::EditorWidth, cx);
 80            editor
 81        });
 82
 83        let channel_select = cx.add_view(|cx| {
 84            let channel_store = channel_store.clone();
 85            Select::new(0, cx, {
 86                move |ix, item_type, is_hovered, cx| {
 87                    Self::render_channel_name(
 88                        &channel_store,
 89                        ix,
 90                        item_type,
 91                        is_hovered,
 92                        &theme::current(cx).chat_panel.channel_select,
 93                        cx,
 94                    )
 95                }
 96            })
 97            .with_style(move |cx| {
 98                let style = &theme::current(cx).chat_panel.channel_select;
 99                SelectStyle {
100                    header: style.header.container,
101                    menu: style.menu,
102                }
103            })
104        });
105
106        let mut message_list =
107            ListState::<Self>::new(0, Orientation::Bottom, 1000., move |this, ix, cx| {
108                let message = this.active_chat.as_ref().unwrap().0.read(cx).message(ix);
109                this.render_message(message, cx)
110            });
111        message_list.set_scroll_handler(|visible_range, this, cx| {
112            if visible_range.start < MESSAGE_LOADING_THRESHOLD {
113                this.load_more_messages(&LoadMoreMessages, cx);
114            }
115        });
116
117        cx.add_view(|cx| {
118            let mut this = Self {
119                fs,
120                client,
121                channel_store,
122                active_chat: Default::default(),
123                pending_serialization: Task::ready(None),
124                message_list,
125                input_editor,
126                channel_select,
127                local_timezone: cx.platform().local_timezone(),
128                has_focus: false,
129                subscriptions: Vec::new(),
130                width: None,
131            };
132
133            let mut old_dock_position = this.position(cx);
134            this.subscriptions
135                .push(
136                    cx.observe_global::<SettingsStore, _>(move |this: &mut Self, cx| {
137                        let new_dock_position = this.position(cx);
138                        if new_dock_position != old_dock_position {
139                            old_dock_position = new_dock_position;
140                            cx.emit(Event::DockPositionChanged);
141                        }
142                        cx.notify();
143                    }),
144                );
145
146            this.init_active_channel(cx);
147            cx.observe(&this.channel_store, |this, _, cx| {
148                this.init_active_channel(cx);
149            })
150            .detach();
151
152            cx.observe(&this.channel_select, |this, channel_select, cx| {
153                let selected_ix = channel_select.read(cx).selected_index();
154
155                let selected_channel_id = this
156                    .channel_store
157                    .read(cx)
158                    .channel_at_index(selected_ix)
159                    .map(|e| e.1.id);
160                if let Some(selected_channel_id) = selected_channel_id {
161                    this.select_channel(selected_channel_id, cx)
162                        .detach_and_log_err(cx);
163                }
164            })
165            .detach();
166
167            this
168        })
169    }
170
171    pub fn load(
172        workspace: WeakViewHandle<Workspace>,
173        cx: AsyncAppContext,
174    ) -> Task<Result<ViewHandle<Self>>> {
175        cx.spawn(|mut cx| async move {
176            let serialized_panel = if let Some(panel) = cx
177                .background()
178                .spawn(async move { KEY_VALUE_STORE.read_kvp(CHAT_PANEL_KEY) })
179                .await
180                .log_err()
181                .flatten()
182            {
183                Some(serde_json::from_str::<SerializedChatPanel>(&panel)?)
184            } else {
185                None
186            };
187
188            workspace.update(&mut cx, |workspace, cx| {
189                let panel = Self::new(workspace, cx);
190                if let Some(serialized_panel) = serialized_panel {
191                    panel.update(cx, |panel, cx| {
192                        panel.width = serialized_panel.width;
193                        cx.notify();
194                    });
195                }
196                panel
197            })
198        })
199    }
200
201    fn serialize(&mut self, cx: &mut ViewContext<Self>) {
202        let width = self.width;
203        self.pending_serialization = cx.background().spawn(
204            async move {
205                KEY_VALUE_STORE
206                    .write_kvp(
207                        CHAT_PANEL_KEY.into(),
208                        serde_json::to_string(&SerializedChatPanel { width })?,
209                    )
210                    .await?;
211                anyhow::Ok(())
212            }
213            .log_err(),
214        );
215    }
216
217    fn init_active_channel(&mut self, cx: &mut ViewContext<Self>) {
218        let channel_count = self.channel_store.read(cx).channel_count();
219        self.message_list.reset(0);
220        self.active_chat = None;
221        self.channel_select.update(cx, |select, cx| {
222            select.set_item_count(channel_count, cx);
223        });
224    }
225
226    fn set_active_chat(&mut self, chat: ModelHandle<ChannelChat>, cx: &mut ViewContext<Self>) {
227        if self.active_chat.as_ref().map(|e| &e.0) != Some(&chat) {
228            let id = chat.read(cx).channel().id;
229            {
230                let chat = chat.read(cx);
231                self.message_list.reset(chat.message_count());
232                let placeholder = format!("Message #{}", chat.channel().name);
233                self.input_editor.update(cx, move |editor, cx| {
234                    editor.set_placeholder_text(placeholder, cx);
235                });
236            }
237            let subscription = cx.subscribe(&chat, Self::channel_did_change);
238            self.active_chat = Some((chat, subscription));
239            self.channel_select.update(cx, |select, cx| {
240                if let Some(ix) = self.channel_store.read(cx).index_of_channel(id) {
241                    select.set_selected_index(ix, cx);
242                }
243            });
244            cx.notify();
245        }
246    }
247
248    fn channel_did_change(
249        &mut self,
250        _: ModelHandle<ChannelChat>,
251        event: &ChannelChatEvent,
252        cx: &mut ViewContext<Self>,
253    ) {
254        match event {
255            ChannelChatEvent::MessagesUpdated {
256                old_range,
257                new_count,
258            } => {
259                self.message_list.splice(old_range.clone(), *new_count);
260            }
261        }
262        cx.notify();
263    }
264
265    fn render_channel(&self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
266        let theme = theme::current(cx);
267        Flex::column()
268            .with_child(
269                ChildView::new(&self.channel_select, cx)
270                    .contained()
271                    .with_style(theme.chat_panel.channel_select.container),
272            )
273            .with_child(self.render_active_channel_messages())
274            .with_child(self.render_input_box(&theme, cx))
275            .into_any()
276    }
277
278    fn render_active_channel_messages(&self) -> AnyElement<Self> {
279        let messages = if self.active_chat.is_some() {
280            List::new(self.message_list.clone()).into_any()
281        } else {
282            Empty::new().into_any()
283        };
284
285        messages.flex(1., true).into_any()
286    }
287
288    fn render_message(&self, message: &ChannelMessage, cx: &AppContext) -> AnyElement<Self> {
289        let now = OffsetDateTime::now_utc();
290        let theme = theme::current(cx);
291        let theme = if message.is_pending() {
292            &theme.chat_panel.pending_message
293        } else {
294            &theme.chat_panel.message
295        };
296
297        Flex::column()
298            .with_child(
299                Flex::row()
300                    .with_child(
301                        Label::new(
302                            message.sender.github_login.clone(),
303                            theme.sender.text.clone(),
304                        )
305                        .contained()
306                        .with_style(theme.sender.container),
307                    )
308                    .with_child(
309                        Label::new(
310                            format_timestamp(message.timestamp, now, self.local_timezone),
311                            theme.timestamp.text.clone(),
312                        )
313                        .contained()
314                        .with_style(theme.timestamp.container),
315                    ),
316            )
317            .with_child(Text::new(message.body.clone(), theme.body.clone()))
318            .contained()
319            .with_style(theme.container)
320            .into_any()
321    }
322
323    fn render_input_box(&self, theme: &Arc<Theme>, cx: &AppContext) -> AnyElement<Self> {
324        ChildView::new(&self.input_editor, cx)
325            .contained()
326            .with_style(theme.chat_panel.input_editor.container)
327            .into_any()
328    }
329
330    fn render_channel_name(
331        channel_store: &ModelHandle<ChannelStore>,
332        ix: usize,
333        item_type: ItemType,
334        is_hovered: bool,
335        theme: &theme::ChannelSelect,
336        cx: &AppContext,
337    ) -> AnyElement<Select> {
338        let channel = &channel_store.read(cx).channel_at_index(ix).unwrap().1;
339        let theme = match (item_type, is_hovered) {
340            (ItemType::Header, _) => &theme.header,
341            (ItemType::Selected, false) => &theme.active_item,
342            (ItemType::Selected, true) => &theme.hovered_active_item,
343            (ItemType::Unselected, false) => &theme.item,
344            (ItemType::Unselected, true) => &theme.hovered_item,
345        };
346        Flex::row()
347            .with_child(
348                Label::new("#".to_string(), theme.hash.text.clone())
349                    .contained()
350                    .with_style(theme.hash.container),
351            )
352            .with_child(Label::new(channel.name.clone(), theme.name.clone()))
353            .contained()
354            .with_style(theme.container)
355            .into_any()
356    }
357
358    fn render_sign_in_prompt(
359        &self,
360        theme: &Arc<Theme>,
361        cx: &mut ViewContext<Self>,
362    ) -> AnyElement<Self> {
363        enum SignInPromptLabel {}
364
365        MouseEventHandler::new::<SignInPromptLabel, _>(0, cx, |mouse_state, _| {
366            Label::new(
367                "Sign in to use chat".to_string(),
368                theme
369                    .chat_panel
370                    .sign_in_prompt
371                    .style_for(mouse_state)
372                    .clone(),
373            )
374        })
375        .with_cursor_style(CursorStyle::PointingHand)
376        .on_click(MouseButton::Left, move |_, this, cx| {
377            let client = this.client.clone();
378            cx.spawn(|this, mut cx| async move {
379                if client
380                    .authenticate_and_connect(true, &cx)
381                    .log_err()
382                    .await
383                    .is_some()
384                {
385                    this.update(&mut cx, |this, cx| {
386                        if cx.handle().is_focused(cx) {
387                            cx.focus(&this.input_editor);
388                        }
389                    })
390                    .ok();
391                }
392            })
393            .detach();
394        })
395        .aligned()
396        .into_any()
397    }
398
399    fn send(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
400        if let Some((chat, _)) = self.active_chat.as_ref() {
401            let body = self.input_editor.update(cx, |editor, cx| {
402                let body = editor.text(cx);
403                editor.clear(cx);
404                body
405            });
406
407            if let Some(task) = chat
408                .update(cx, |chat, cx| chat.send_message(body, cx))
409                .log_err()
410            {
411                task.detach();
412            }
413        }
414    }
415
416    fn load_more_messages(&mut self, _: &LoadMoreMessages, cx: &mut ViewContext<Self>) {
417        if let Some((chat, _)) = self.active_chat.as_ref() {
418            chat.update(cx, |channel, cx| {
419                channel.load_more_messages(cx);
420            })
421        }
422    }
423
424    pub fn select_channel(
425        &mut self,
426        selected_channel_id: u64,
427        cx: &mut ViewContext<ChatPanel>,
428    ) -> Task<Result<()>> {
429        if let Some((chat, _)) = &self.active_chat {
430            if chat.read(cx).channel().id == selected_channel_id {
431                return Task::ready(Ok(()));
432            }
433        }
434
435        let open_chat = self.channel_store.update(cx, |store, cx| {
436            store.open_channel_chat(selected_channel_id, cx)
437        });
438        cx.spawn(|this, mut cx| async move {
439            let chat = open_chat.await?;
440            this.update(&mut cx, |this, cx| {
441                this.set_active_chat(chat, cx);
442            })
443        })
444    }
445}
446
447impl Entity for ChatPanel {
448    type Event = Event;
449}
450
451impl View for ChatPanel {
452    fn ui_name() -> &'static str {
453        "ChatPanel"
454    }
455
456    fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
457        let theme = theme::current(cx);
458        let element = if self.client.user_id().is_some() {
459            self.render_channel(cx)
460        } else {
461            self.render_sign_in_prompt(&theme, cx)
462        };
463        element
464            .contained()
465            .with_style(theme.chat_panel.container)
466            .constrained()
467            .with_min_width(150.)
468            .into_any()
469    }
470
471    fn focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {
472        if matches!(
473            *self.client.status().borrow(),
474            client::Status::Connected { .. }
475        ) {
476            cx.focus(&self.input_editor);
477        }
478    }
479}
480
481impl Panel for ChatPanel {
482    fn position(&self, cx: &gpui::WindowContext) -> DockPosition {
483        settings::get::<ChatPanelSettings>(cx).dock
484    }
485
486    fn position_is_valid(&self, position: DockPosition) -> bool {
487        matches!(position, DockPosition::Left | DockPosition::Right)
488    }
489
490    fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
491        settings::update_settings_file::<ChatPanelSettings>(self.fs.clone(), cx, move |settings| {
492            settings.dock = Some(position)
493        });
494    }
495
496    fn size(&self, cx: &gpui::WindowContext) -> f32 {
497        self.width
498            .unwrap_or_else(|| settings::get::<ChatPanelSettings>(cx).default_width)
499    }
500
501    fn set_size(&mut self, size: Option<f32>, cx: &mut ViewContext<Self>) {
502        self.width = size;
503        self.serialize(cx);
504        cx.notify();
505    }
506
507    fn icon_path(&self, cx: &gpui::WindowContext) -> Option<&'static str> {
508        settings::get::<ChatPanelSettings>(cx)
509            .button
510            .then(|| "icons/conversations.svg")
511    }
512
513    fn icon_tooltip(&self) -> (String, Option<Box<dyn gpui::Action>>) {
514        ("Chat Panel".to_string(), Some(Box::new(ToggleFocus)))
515    }
516
517    fn should_change_position_on_event(event: &Self::Event) -> bool {
518        matches!(event, Event::DockPositionChanged)
519    }
520
521    fn has_focus(&self, _cx: &gpui::WindowContext) -> bool {
522        self.has_focus
523    }
524
525    fn is_focus_event(event: &Self::Event) -> bool {
526        matches!(event, Event::Focus)
527    }
528}
529
530fn format_timestamp(
531    mut timestamp: OffsetDateTime,
532    mut now: OffsetDateTime,
533    local_timezone: UtcOffset,
534) -> String {
535    timestamp = timestamp.to_offset(local_timezone);
536    now = now.to_offset(local_timezone);
537
538    let today = now.date();
539    let date = timestamp.date();
540    let mut hour = timestamp.hour();
541    let mut part = "am";
542    if hour > 12 {
543        hour -= 12;
544        part = "pm";
545    }
546    if date == today {
547        format!("{:02}:{:02}{}", hour, timestamp.minute(), part)
548    } else if date.next_day() == Some(today) {
549        format!("yesterday at {:02}:{:02}{}", hour, timestamp.minute(), part)
550    } else {
551        format!("{:02}/{}/{}", date.month() as u32, date.day(), date.year())
552    }
553}