chat_panel.rs

  1use client::{
  2    channel::{Channel, ChannelEvent, ChannelList, ChannelMessage},
  3    Client,
  4};
  5use editor::Editor;
  6use gpui::{
  7    action,
  8    elements::*,
  9    keymap::Binding,
 10    platform::CursorStyle,
 11    views::{ItemType, Select, SelectStyle},
 12    AppContext, Entity, ModelHandle, MutableAppContext, RenderContext, Subscription, Task, View,
 13    ViewContext, ViewHandle,
 14};
 15use postage::prelude::Stream;
 16use std::sync::Arc;
 17use time::{OffsetDateTime, UtcOffset};
 18use util::{ResultExt, TryFutureExt};
 19use workspace::{settings::SoftWrap, Settings};
 20
 21const MESSAGE_LOADING_THRESHOLD: usize = 50;
 22
 23pub struct ChatPanel {
 24    rpc: Arc<Client>,
 25    channel_list: ModelHandle<ChannelList>,
 26    active_channel: Option<(ModelHandle<Channel>, Subscription)>,
 27    message_list: ListState,
 28    input_editor: ViewHandle<Editor>,
 29    channel_select: ViewHandle<Select>,
 30    local_timezone: UtcOffset,
 31    _observe_status: Task<()>,
 32}
 33
 34pub enum Event {}
 35
 36action!(Send);
 37action!(LoadMoreMessages);
 38
 39pub fn init(cx: &mut MutableAppContext) {
 40    cx.add_action(ChatPanel::send);
 41    cx.add_action(ChatPanel::load_more_messages);
 42
 43    cx.add_bindings(vec![Binding::new("enter", Send, Some("ChatPanel"))]);
 44}
 45
 46impl ChatPanel {
 47    pub fn new(
 48        rpc: Arc<Client>,
 49        channel_list: ModelHandle<ChannelList>,
 50        cx: &mut ViewContext<Self>,
 51    ) -> Self {
 52        let input_editor = cx.add_view(|cx| {
 53            let mut editor =
 54                Editor::auto_height(4, Some(|theme| theme.chat_panel.input_editor.clone()), cx);
 55            editor.set_soft_wrap_mode(SoftWrap::EditorWidth, cx);
 56            editor
 57        });
 58        let channel_select = cx.add_view(|cx| {
 59            let channel_list = channel_list.clone();
 60            Select::new(0, cx, {
 61                move |ix, item_type, is_hovered, cx| {
 62                    Self::render_channel_name(
 63                        &channel_list,
 64                        ix,
 65                        item_type,
 66                        is_hovered,
 67                        &cx.app_state::<Settings>().theme.chat_panel.channel_select,
 68                        cx,
 69                    )
 70                }
 71            })
 72            .with_style(move |cx| {
 73                let theme = &cx.app_state::<Settings>().theme.chat_panel.channel_select;
 74                SelectStyle {
 75                    header: theme.header.container.clone(),
 76                    menu: theme.menu.clone(),
 77                }
 78            })
 79        });
 80
 81        let mut message_list = ListState::new(0, Orientation::Bottom, 1000., {
 82            let this = cx.weak_handle();
 83            move |ix, cx| {
 84                let this = this.upgrade(cx).unwrap().read(cx);
 85                let message = this.active_channel.as_ref().unwrap().0.read(cx).message(ix);
 86                this.render_message(message, cx)
 87            }
 88        });
 89        message_list.set_scroll_handler(|visible_range, cx| {
 90            if visible_range.start < MESSAGE_LOADING_THRESHOLD {
 91                cx.dispatch_action(LoadMoreMessages);
 92            }
 93        });
 94        let _observe_status = cx.spawn_weak(|this, mut cx| {
 95            let mut status = rpc.status();
 96            async move {
 97                while let Some(_) = status.recv().await {
 98                    if let Some(this) = this.upgrade(&cx) {
 99                        this.update(&mut cx, |_, cx| cx.notify());
100                    } else {
101                        break;
102                    }
103                }
104            }
105        });
106
107        let mut this = Self {
108            rpc,
109            channel_list,
110            active_channel: Default::default(),
111            message_list,
112            input_editor,
113            channel_select,
114            local_timezone: cx.platform().local_timezone(),
115            _observe_status,
116        };
117
118        this.init_active_channel(cx);
119        cx.observe(&this.channel_list, |this, _, cx| {
120            this.init_active_channel(cx);
121        })
122        .detach();
123        cx.observe(&this.channel_select, |this, channel_select, cx| {
124            let selected_ix = channel_select.read(cx).selected_index();
125            let selected_channel = this.channel_list.update(cx, |channel_list, cx| {
126                let available_channels = channel_list.available_channels()?;
127                let channel_id = available_channels.get(selected_ix)?.id;
128                channel_list.get_channel(channel_id, cx)
129            });
130            if let Some(selected_channel) = selected_channel {
131                this.set_active_channel(selected_channel, cx);
132            }
133        })
134        .detach();
135
136        this
137    }
138
139    fn init_active_channel(&mut self, cx: &mut ViewContext<Self>) {
140        let (active_channel, channel_count) = self.channel_list.update(cx, |list, cx| {
141            let channel_count;
142            let mut active_channel = None;
143
144            if let Some(available_channels) = list.available_channels() {
145                channel_count = available_channels.len();
146                if self.active_channel.is_none() {
147                    if let Some(channel_id) = available_channels.first().map(|channel| channel.id) {
148                        active_channel = list.get_channel(channel_id, cx);
149                    }
150                }
151            } else {
152                channel_count = 0;
153            }
154
155            (active_channel, channel_count)
156        });
157
158        if let Some(active_channel) = active_channel {
159            self.set_active_channel(active_channel, cx);
160        } else {
161            self.message_list.reset(0);
162            self.active_channel = None;
163        }
164
165        self.channel_select.update(cx, |select, cx| {
166            select.set_item_count(channel_count, cx);
167        });
168    }
169
170    fn set_active_channel(&mut self, channel: ModelHandle<Channel>, cx: &mut ViewContext<Self>) {
171        if self.active_channel.as_ref().map(|e| &e.0) != Some(&channel) {
172            {
173                let channel = channel.read(cx);
174                self.message_list.reset(channel.message_count());
175                let placeholder = format!("Message #{}", channel.name());
176                self.input_editor.update(cx, move |editor, cx| {
177                    editor.set_placeholder_text(placeholder, cx);
178                });
179            }
180            let subscription = cx.subscribe(&channel, Self::channel_did_change);
181            self.active_channel = Some((channel, subscription));
182        }
183    }
184
185    fn channel_did_change(
186        &mut self,
187        _: ModelHandle<Channel>,
188        event: &ChannelEvent,
189        cx: &mut ViewContext<Self>,
190    ) {
191        match event {
192            ChannelEvent::MessagesUpdated {
193                old_range,
194                new_count,
195            } => {
196                self.message_list.splice(old_range.clone(), *new_count);
197            }
198        }
199        cx.notify();
200    }
201
202    fn render_channel(&self, cx: &mut RenderContext<Self>) -> ElementBox {
203        let theme = &cx.app_state::<Settings>().theme;
204        Flex::column()
205            .with_child(
206                Container::new(ChildView::new(&self.channel_select).boxed())
207                    .with_style(theme.chat_panel.channel_select.container)
208                    .boxed(),
209            )
210            .with_child(self.render_active_channel_messages())
211            .with_child(self.render_input_box(cx))
212            .boxed()
213    }
214
215    fn render_active_channel_messages(&self) -> ElementBox {
216        let messages = if self.active_channel.is_some() {
217            List::new(self.message_list.clone()).boxed()
218        } else {
219            Empty::new().boxed()
220        };
221
222        Flexible::new(1., true, messages).boxed()
223    }
224
225    fn render_message(&self, message: &ChannelMessage, cx: &AppContext) -> ElementBox {
226        let now = OffsetDateTime::now_utc();
227        let settings = cx.app_state::<Settings>();
228        let theme = if message.is_pending() {
229            &settings.theme.chat_panel.pending_message
230        } else {
231            &settings.theme.chat_panel.message
232        };
233
234        Container::new(
235            Flex::column()
236                .with_child(
237                    Flex::row()
238                        .with_child(
239                            Container::new(
240                                Label::new(
241                                    message.sender.github_login.clone(),
242                                    theme.sender.text.clone(),
243                                )
244                                .boxed(),
245                            )
246                            .with_style(theme.sender.container)
247                            .boxed(),
248                        )
249                        .with_child(
250                            Container::new(
251                                Label::new(
252                                    format_timestamp(message.timestamp, now, self.local_timezone),
253                                    theme.timestamp.text.clone(),
254                                )
255                                .boxed(),
256                            )
257                            .with_style(theme.timestamp.container)
258                            .boxed(),
259                        )
260                        .boxed(),
261                )
262                .with_child(Text::new(message.body.clone(), theme.body.clone()).boxed())
263                .boxed(),
264        )
265        .with_style(theme.container)
266        .boxed()
267    }
268
269    fn render_input_box(&self, cx: &AppContext) -> ElementBox {
270        let theme = &cx.app_state::<Settings>().theme;
271        Container::new(ChildView::new(&self.input_editor).boxed())
272            .with_style(theme.chat_panel.input_editor.container)
273            .boxed()
274    }
275
276    fn render_channel_name(
277        channel_list: &ModelHandle<ChannelList>,
278        ix: usize,
279        item_type: ItemType,
280        is_hovered: bool,
281        theme: &theme::ChannelSelect,
282        cx: &AppContext,
283    ) -> ElementBox {
284        let channel = &channel_list.read(cx).available_channels().unwrap()[ix];
285        let theme = match (item_type, is_hovered) {
286            (ItemType::Header, _) => &theme.header,
287            (ItemType::Selected, false) => &theme.active_item,
288            (ItemType::Selected, true) => &theme.hovered_active_item,
289            (ItemType::Unselected, false) => &theme.item,
290            (ItemType::Unselected, true) => &theme.hovered_item,
291        };
292        Container::new(
293            Flex::row()
294                .with_child(
295                    Container::new(Label::new("#".to_string(), theme.hash.text.clone()).boxed())
296                        .with_style(theme.hash.container)
297                        .boxed(),
298                )
299                .with_child(Label::new(channel.name.clone(), theme.name.clone()).boxed())
300                .boxed(),
301        )
302        .with_style(theme.container)
303        .boxed()
304    }
305
306    fn render_sign_in_prompt(&self, cx: &mut RenderContext<Self>) -> ElementBox {
307        let theme = cx.app_state::<Settings>().theme.clone();
308        let rpc = self.rpc.clone();
309        let this = cx.handle();
310
311        enum SignInPromptLabel {}
312
313        Align::new(
314            MouseEventHandler::new::<SignInPromptLabel, _, _>(0, cx, |mouse_state, _| {
315                Label::new(
316                    "Sign in to use chat".to_string(),
317                    if mouse_state.hovered {
318                        theme.chat_panel.hovered_sign_in_prompt.clone()
319                    } else {
320                        theme.chat_panel.sign_in_prompt.clone()
321                    },
322                )
323                .boxed()
324            })
325            .with_cursor_style(CursorStyle::PointingHand)
326            .on_click(move |cx| {
327                let rpc = rpc.clone();
328                let this = this.clone();
329                cx.spawn(|mut cx| async move {
330                    if rpc.authenticate_and_connect(&cx).log_err().await.is_some() {
331                        cx.update(|cx| {
332                            if let Some(this) = this.upgrade(cx) {
333                                if this.is_focused(cx) {
334                                    this.update(cx, |this, cx| cx.focus(&this.input_editor));
335                                }
336                            }
337                        })
338                    }
339                })
340                .detach();
341            })
342            .boxed(),
343        )
344        .boxed()
345    }
346
347    fn send(&mut self, _: &Send, cx: &mut ViewContext<Self>) {
348        if let Some((channel, _)) = self.active_channel.as_ref() {
349            let body = self.input_editor.update(cx, |editor, cx| {
350                let body = editor.text(cx);
351                editor.clear(cx);
352                body
353            });
354
355            if let Some(task) = channel
356                .update(cx, |channel, cx| channel.send_message(body, cx))
357                .log_err()
358            {
359                task.detach();
360            }
361        }
362    }
363
364    fn load_more_messages(&mut self, _: &LoadMoreMessages, cx: &mut ViewContext<Self>) {
365        if let Some((channel, _)) = self.active_channel.as_ref() {
366            channel.update(cx, |channel, cx| {
367                channel.load_more_messages(cx);
368            })
369        }
370    }
371}
372
373impl Entity for ChatPanel {
374    type Event = Event;
375}
376
377impl View for ChatPanel {
378    fn ui_name() -> &'static str {
379        "ChatPanel"
380    }
381
382    fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
383        let element = if self.rpc.user_id().is_some() {
384            self.render_channel(cx)
385        } else {
386            self.render_sign_in_prompt(cx)
387        };
388        let theme = &cx.app_state::<Settings>().theme;
389        ConstrainedBox::new(
390            Container::new(element)
391                .with_style(theme.chat_panel.container)
392                .boxed(),
393        )
394        .with_min_width(150.)
395        .boxed()
396    }
397
398    fn on_focus(&mut self, cx: &mut ViewContext<Self>) {
399        if matches!(
400            *self.rpc.status().borrow(),
401            client::Status::Connected { .. }
402        ) {
403            cx.focus(&self.input_editor);
404        }
405    }
406}
407
408fn format_timestamp(
409    mut timestamp: OffsetDateTime,
410    mut now: OffsetDateTime,
411    local_timezone: UtcOffset,
412) -> String {
413    timestamp = timestamp.to_offset(local_timezone);
414    now = now.to_offset(local_timezone);
415
416    let today = now.date();
417    let date = timestamp.date();
418    let mut hour = timestamp.hour();
419    let mut part = "am";
420    if hour > 12 {
421        hour -= 12;
422        part = "pm";
423    }
424    if date == today {
425        format!("{:02}:{:02}{}", hour, timestamp.minute(), part)
426    } else if date.next_day() == Some(today) {
427        format!("yesterday at {:02}:{:02}{}", hour, timestamp.minute(), part)
428    } else {
429        format!("{:02}/{}/{}", date.month() as u32, date.day(), date.year())
430    }
431}