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}