1use crate::{ChatPanelButton, ChatPanelSettings, collab_panel};
2use anyhow::Result;
3use call::{ActiveCall, room};
4use channel::{ChannelChat, ChannelChatEvent, ChannelMessage, ChannelMessageId, ChannelStore};
5use client::{ChannelId, Client};
6use collections::HashMap;
7use db::kvp::KEY_VALUE_STORE;
8use editor::{Editor, actions};
9use gpui::{
10 Action, App, AsyncWindowContext, ClipboardItem, Context, CursorStyle, DismissEvent, ElementId,
11 Entity, EventEmitter, FocusHandle, Focusable, FontWeight, HighlightStyle, ListOffset,
12 ListScrollEvent, ListState, Render, Stateful, Subscription, Task, WeakEntity, Window, actions,
13 div, list, prelude::*, px,
14};
15use language::LanguageRegistry;
16use menu::Confirm;
17use message_editor::MessageEditor;
18use project::Fs;
19use rich_text::{Highlight, RichText};
20use serde::{Deserialize, Serialize};
21use settings::Settings;
22use std::{sync::Arc, time::Duration};
23use time::{OffsetDateTime, UtcOffset};
24use ui::{
25 Avatar, Button, ContextMenu, IconButton, IconName, KeyBinding, Label, PopoverMenu, Tab, TabBar,
26 Tooltip, prelude::*,
27};
28use util::{ResultExt, TryFutureExt};
29use workspace::{
30 Workspace,
31 dock::{DockPosition, Panel, PanelEvent},
32};
33
34mod message_editor;
35
36const MESSAGE_LOADING_THRESHOLD: usize = 50;
37const CHAT_PANEL_KEY: &str = "ChatPanel";
38
39pub fn init(cx: &mut App) {
40 cx.observe_new(|workspace: &mut Workspace, _, _| {
41 workspace.register_action(|workspace, _: &ToggleFocus, window, cx| {
42 workspace.toggle_panel_focus::<ChatPanel>(window, cx);
43 });
44 })
45 .detach();
46}
47
48pub struct ChatPanel {
49 client: Arc<Client>,
50 channel_store: Entity<ChannelStore>,
51 languages: Arc<LanguageRegistry>,
52 message_list: ListState,
53 active_chat: Option<(Entity<ChannelChat>, Subscription)>,
54 message_editor: Entity<MessageEditor>,
55 local_timezone: UtcOffset,
56 fs: Arc<dyn Fs>,
57 width: Option<Pixels>,
58 active: bool,
59 pending_serialization: Task<Option<()>>,
60 subscriptions: Vec<gpui::Subscription>,
61 is_scrolled_to_bottom: bool,
62 markdown_data: HashMap<ChannelMessageId, RichText>,
63 focus_handle: FocusHandle,
64 open_context_menu: Option<(u64, Subscription)>,
65 highlighted_message: Option<(u64, Task<()>)>,
66 last_acknowledged_message_id: Option<u64>,
67}
68
69#[derive(Serialize, Deserialize)]
70struct SerializedChatPanel {
71 width: Option<Pixels>,
72}
73
74actions!(
75 chat_panel,
76 [
77 /// Toggles focus on the chat panel.
78 ToggleFocus
79 ]
80);
81
82impl ChatPanel {
83 pub fn new(
84 workspace: &mut Workspace,
85 window: &mut Window,
86 cx: &mut Context<Workspace>,
87 ) -> Entity<Self> {
88 let fs = workspace.app_state().fs.clone();
89 let client = workspace.app_state().client.clone();
90 let channel_store = ChannelStore::global(cx);
91 let user_store = workspace.app_state().user_store.clone();
92 let languages = workspace.app_state().languages.clone();
93
94 let input_editor = cx.new(|cx| {
95 MessageEditor::new(
96 languages.clone(),
97 user_store.clone(),
98 None,
99 cx.new(|cx| Editor::auto_height(1, 4, window, cx)),
100 window,
101 cx,
102 )
103 });
104
105 cx.new(|cx| {
106 let message_list = ListState::new(0, gpui::ListAlignment::Bottom, px(1000.));
107
108 message_list.set_scroll_handler(cx.listener(
109 |this: &mut Self, event: &ListScrollEvent, _, cx| {
110 if event.visible_range.start < MESSAGE_LOADING_THRESHOLD {
111 this.load_more_messages(cx);
112 }
113 this.is_scrolled_to_bottom = !event.is_scrolled;
114 },
115 ));
116
117 let local_offset = chrono::Local::now().offset().local_minus_utc();
118 let mut this = Self {
119 fs,
120 client,
121 channel_store,
122 languages,
123 message_list,
124 active_chat: Default::default(),
125 pending_serialization: Task::ready(None),
126 message_editor: input_editor,
127 local_timezone: UtcOffset::from_whole_seconds(local_offset).unwrap(),
128 subscriptions: Vec::new(),
129 is_scrolled_to_bottom: true,
130 active: false,
131 width: None,
132 markdown_data: Default::default(),
133 focus_handle: cx.focus_handle(),
134 open_context_menu: None,
135 highlighted_message: None,
136 last_acknowledged_message_id: None,
137 };
138
139 if let Some(channel_id) = ActiveCall::global(cx)
140 .read(cx)
141 .room()
142 .and_then(|room| room.read(cx).channel_id())
143 {
144 this.select_channel(channel_id, None, cx)
145 .detach_and_log_err(cx);
146 }
147
148 this.subscriptions.push(cx.subscribe(
149 &ActiveCall::global(cx),
150 move |this: &mut Self, call, event: &room::Event, cx| match event {
151 room::Event::RoomJoined { channel_id } => {
152 if let Some(channel_id) = channel_id {
153 this.select_channel(*channel_id, None, cx)
154 .detach_and_log_err(cx);
155
156 if call
157 .read(cx)
158 .room()
159 .is_some_and(|room| room.read(cx).contains_guests())
160 {
161 cx.emit(PanelEvent::Activate)
162 }
163 }
164 }
165 room::Event::RoomLeft { channel_id } => {
166 if channel_id == &this.channel_id(cx) {
167 cx.emit(PanelEvent::Close)
168 }
169 }
170 _ => {}
171 },
172 ));
173
174 this
175 })
176 }
177
178 pub fn channel_id(&self, cx: &App) -> Option<ChannelId> {
179 self.active_chat
180 .as_ref()
181 .map(|(chat, _)| chat.read(cx).channel_id)
182 }
183
184 pub fn is_scrolled_to_bottom(&self) -> bool {
185 self.is_scrolled_to_bottom
186 }
187
188 pub fn active_chat(&self) -> Option<Entity<ChannelChat>> {
189 self.active_chat.as_ref().map(|(chat, _)| chat.clone())
190 }
191
192 pub fn load(
193 workspace: WeakEntity<Workspace>,
194 cx: AsyncWindowContext,
195 ) -> Task<Result<Entity<Self>>> {
196 cx.spawn(async move |cx| {
197 let serialized_panel = if let Some(panel) = cx
198 .background_spawn(async move { KEY_VALUE_STORE.read_kvp(CHAT_PANEL_KEY) })
199 .await
200 .log_err()
201 .flatten()
202 {
203 Some(serde_json::from_str::<SerializedChatPanel>(&panel)?)
204 } else {
205 None
206 };
207
208 workspace.update_in(cx, |workspace, window, cx| {
209 let panel = Self::new(workspace, window, cx);
210 if let Some(serialized_panel) = serialized_panel {
211 panel.update(cx, |panel, cx| {
212 panel.width = serialized_panel.width.map(|r| r.round());
213 cx.notify();
214 });
215 }
216 panel
217 })
218 })
219 }
220
221 fn serialize(&mut self, cx: &mut Context<Self>) {
222 let width = self.width;
223 self.pending_serialization = cx.background_spawn(
224 async move {
225 KEY_VALUE_STORE
226 .write_kvp(
227 CHAT_PANEL_KEY.into(),
228 serde_json::to_string(&SerializedChatPanel { width })?,
229 )
230 .await?;
231 anyhow::Ok(())
232 }
233 .log_err(),
234 );
235 }
236
237 fn set_active_chat(&mut self, chat: Entity<ChannelChat>, cx: &mut Context<Self>) {
238 if self.active_chat.as_ref().map(|e| &e.0) != Some(&chat) {
239 self.markdown_data.clear();
240 self.message_list.reset(chat.read(cx).message_count());
241 self.message_editor.update(cx, |editor, cx| {
242 editor.set_channel_chat(chat.clone(), cx);
243 editor.clear_reply_to_message_id();
244 });
245 let subscription = cx.subscribe(&chat, Self::channel_did_change);
246 self.active_chat = Some((chat, subscription));
247 self.acknowledge_last_message(cx);
248 cx.notify();
249 }
250 }
251
252 fn channel_did_change(
253 &mut self,
254 _: Entity<ChannelChat>,
255 event: &ChannelChatEvent,
256 cx: &mut Context<Self>,
257 ) {
258 match event {
259 ChannelChatEvent::MessagesUpdated {
260 old_range,
261 new_count,
262 } => {
263 self.message_list.splice(old_range.clone(), *new_count);
264 if self.active {
265 self.acknowledge_last_message(cx);
266 }
267 }
268 ChannelChatEvent::UpdateMessage {
269 message_id,
270 message_ix,
271 } => {
272 self.message_list.splice(*message_ix..*message_ix + 1, 1);
273 self.markdown_data.remove(message_id);
274 }
275 ChannelChatEvent::NewMessage {
276 channel_id,
277 message_id,
278 } => {
279 if !self.active {
280 self.channel_store.update(cx, |store, cx| {
281 store.update_latest_message_id(*channel_id, *message_id, cx)
282 })
283 }
284 }
285 }
286 cx.notify();
287 }
288
289 fn acknowledge_last_message(&mut self, cx: &mut Context<Self>) {
290 if self.active && self.is_scrolled_to_bottom
291 && let Some((chat, _)) = &self.active_chat {
292 if let Some(channel_id) = self.channel_id(cx) {
293 self.last_acknowledged_message_id = self
294 .channel_store
295 .read(cx)
296 .last_acknowledge_message_id(channel_id);
297 }
298
299 chat.update(cx, |chat, cx| {
300 chat.acknowledge_last_message(cx);
301 });
302 }
303 }
304
305 fn render_replied_to_message(
306 &mut self,
307 message_id: Option<ChannelMessageId>,
308 reply_to_message: &Option<ChannelMessage>,
309 cx: &mut Context<Self>,
310 ) -> impl IntoElement {
311 let reply_to_message = match reply_to_message {
312 None => {
313 return div().child(
314 h_flex()
315 .text_ui_xs(cx)
316 .my_0p5()
317 .px_0p5()
318 .gap_x_1()
319 .rounded_sm()
320 .child(Icon::new(IconName::ReplyArrowRight).color(Color::Muted))
321 .when(reply_to_message.is_none(), |el| {
322 el.child(
323 Label::new("Message has been deleted...")
324 .size(LabelSize::XSmall)
325 .color(Color::Muted),
326 )
327 }),
328 );
329 }
330 Some(val) => val,
331 };
332
333 let user_being_replied_to = reply_to_message.sender.clone();
334 let message_being_replied_to = reply_to_message.clone();
335
336 let message_element_id: ElementId = match message_id {
337 Some(ChannelMessageId::Saved(id)) => ("reply-to-saved-message-container", id).into(),
338 Some(ChannelMessageId::Pending(id)) => {
339 ("reply-to-pending-message-container", id).into()
340 } // This should never happen
341 None => ("composing-reply-container").into(),
342 };
343
344 let current_channel_id = self.channel_id(cx);
345 let reply_to_message_id = reply_to_message.id;
346
347 div().child(
348 h_flex()
349 .id(message_element_id)
350 .text_ui_xs(cx)
351 .my_0p5()
352 .px_0p5()
353 .gap_x_1()
354 .rounded_sm()
355 .overflow_hidden()
356 .hover(|style| style.bg(cx.theme().colors().element_background))
357 .child(Icon::new(IconName::ReplyArrowRight).color(Color::Muted))
358 .child(Avatar::new(user_being_replied_to.avatar_uri.clone()).size(rems(0.7)))
359 .child(
360 Label::new(format!("@{}", user_being_replied_to.github_login))
361 .size(LabelSize::XSmall)
362 .weight(FontWeight::SEMIBOLD)
363 .color(Color::Muted),
364 )
365 .child(
366 div().overflow_y_hidden().child(
367 Label::new(message_being_replied_to.body.replace('\n', " "))
368 .size(LabelSize::XSmall)
369 .color(Color::Default),
370 ),
371 )
372 .cursor(CursorStyle::PointingHand)
373 .tooltip(Tooltip::text("Go to message"))
374 .on_click(cx.listener(move |chat_panel, _, _, cx| {
375 if let Some(channel_id) = current_channel_id {
376 chat_panel
377 .select_channel(channel_id, reply_to_message_id.into(), cx)
378 .detach_and_log_err(cx)
379 }
380 })),
381 )
382 }
383
384 fn render_message(
385 &mut self,
386 ix: usize,
387 window: &mut Window,
388 cx: &mut Context<Self>,
389 ) -> AnyElement {
390 let active_chat = &self.active_chat.as_ref().unwrap().0;
391 let (message, is_continuation_from_previous, is_admin) =
392 active_chat.update(cx, |active_chat, cx| {
393 let is_admin = self
394 .channel_store
395 .read(cx)
396 .is_channel_admin(active_chat.channel_id);
397
398 let last_message = active_chat.message(ix.saturating_sub(1));
399 let this_message = active_chat.message(ix).clone();
400
401 let duration_since_last_message = this_message.timestamp - last_message.timestamp;
402 let is_continuation_from_previous = last_message.sender.id
403 == this_message.sender.id
404 && last_message.id != this_message.id
405 && duration_since_last_message < Duration::from_secs(5 * 60);
406
407 if let ChannelMessageId::Saved(id) = this_message.id
408 && this_message
409 .mentions
410 .iter()
411 .any(|(_, user_id)| Some(*user_id) == self.client.user_id())
412 {
413 active_chat.acknowledge_message(id);
414 }
415
416 (this_message, is_continuation_from_previous, is_admin)
417 });
418
419 let _is_pending = message.is_pending();
420
421 let belongs_to_user = Some(message.sender.id) == self.client.user_id();
422 let can_delete_message = belongs_to_user || is_admin;
423 let can_edit_message = belongs_to_user;
424
425 let element_id: ElementId = match message.id {
426 ChannelMessageId::Saved(id) => ("saved-message", id).into(),
427 ChannelMessageId::Pending(id) => ("pending-message", id).into(),
428 };
429
430 let mentioning_you = message
431 .mentions
432 .iter()
433 .any(|m| Some(m.1) == self.client.user_id());
434
435 let message_id = match message.id {
436 ChannelMessageId::Saved(id) => Some(id),
437 ChannelMessageId::Pending(_) => None,
438 };
439
440 let reply_to_message = message
441 .reply_to_message_id
442 .and_then(|id| active_chat.read(cx).find_loaded_message(id))
443 .cloned();
444
445 let replied_to_you =
446 reply_to_message.as_ref().map(|m| m.sender.id) == self.client.user_id();
447
448 let is_highlighted_message = self
449 .highlighted_message
450 .as_ref()
451 .is_some_and(|(id, _)| Some(id) == message_id.as_ref());
452 let background = if is_highlighted_message {
453 cx.theme().status().info_background
454 } else if mentioning_you || replied_to_you {
455 cx.theme().colors().background
456 } else {
457 cx.theme().colors().panel_background
458 };
459
460 let reply_to_message_id = self.message_editor.read(cx).reply_to_message_id();
461
462 v_flex()
463 .w_full()
464 .relative()
465 .group("")
466 .when(!is_continuation_from_previous, |this| this.pt_2())
467 .child(
468 div()
469 .group("")
470 .bg(background)
471 .rounded_sm()
472 .overflow_hidden()
473 .px_1p5()
474 .py_0p5()
475 .when_some(reply_to_message_id, |el, reply_id| {
476 el.when_some(message_id, |el, message_id| {
477 el.when(reply_id == message_id, |el| {
478 el.bg(cx.theme().colors().element_selected)
479 })
480 })
481 })
482 .when(!self.has_open_menu(message_id), |this| {
483 this.hover(|style| style.bg(cx.theme().colors().element_hover))
484 })
485 .when(message.reply_to_message_id.is_some(), |el| {
486 el.child(self.render_replied_to_message(
487 Some(message.id),
488 &reply_to_message,
489 cx,
490 ))
491 .when(is_continuation_from_previous, |this| this.mt_2())
492 })
493 .when(
494 !is_continuation_from_previous || message.reply_to_message_id.is_some(),
495 |this| {
496 this.child(
497 h_flex()
498 .gap_2()
499 .text_ui_sm(cx)
500 .child(
501 Avatar::new(message.sender.avatar_uri.clone())
502 .size(rems(1.)),
503 )
504 .child(
505 Label::new(message.sender.github_login.clone())
506 .size(LabelSize::Small)
507 .weight(FontWeight::BOLD),
508 )
509 .child(
510 Label::new(time_format::format_localized_timestamp(
511 message.timestamp,
512 OffsetDateTime::now_utc(),
513 self.local_timezone,
514 time_format::TimestampFormat::EnhancedAbsolute,
515 ))
516 .size(LabelSize::Small)
517 .color(Color::Muted),
518 ),
519 )
520 },
521 )
522 .when(mentioning_you || replied_to_you, |this| this.my_0p5())
523 .map(|el| {
524 let text = self.markdown_data.entry(message.id).or_insert_with(|| {
525 Self::render_markdown_with_mentions(
526 &self.languages,
527 self.client.id(),
528 &message,
529 self.local_timezone,
530 cx,
531 )
532 });
533 el.child(
534 v_flex()
535 .w_full()
536 .text_ui_sm(cx)
537 .id(element_id)
538 .child(text.element("body".into(), window, cx)),
539 )
540 .when(self.has_open_menu(message_id), |el| {
541 el.bg(cx.theme().colors().element_selected)
542 })
543 }),
544 )
545 .when(
546 self.last_acknowledged_message_id
547 .is_some_and(|l| Some(l) == message_id),
548 |this| {
549 this.child(
550 h_flex()
551 .py_2()
552 .gap_1()
553 .items_center()
554 .child(div().w_full().h_0p5().bg(cx.theme().colors().border))
555 .child(
556 div()
557 .px_1()
558 .rounded_sm()
559 .text_ui_xs(cx)
560 .bg(cx.theme().colors().background)
561 .child("New messages"),
562 )
563 .child(div().w_full().h_0p5().bg(cx.theme().colors().border)),
564 )
565 },
566 )
567 .child(
568 self.render_popover_buttons(message_id, can_delete_message, can_edit_message, cx)
569 .mt_neg_2p5(),
570 )
571 .into_any_element()
572 }
573
574 fn has_open_menu(&self, message_id: Option<u64>) -> bool {
575 match self.open_context_menu.as_ref() {
576 Some((id, _)) => Some(*id) == message_id,
577 None => false,
578 }
579 }
580
581 fn render_popover_button(&self, cx: &mut Context<Self>, child: Stateful<Div>) -> Div {
582 div()
583 .w_6()
584 .bg(cx.theme().colors().element_background)
585 .hover(|style| style.bg(cx.theme().colors().element_hover).rounded_sm())
586 .child(child)
587 }
588
589 fn render_popover_buttons(
590 &self,
591 message_id: Option<u64>,
592 can_delete_message: bool,
593 can_edit_message: bool,
594 cx: &mut Context<Self>,
595 ) -> Div {
596 h_flex()
597 .absolute()
598 .right_2()
599 .overflow_hidden()
600 .rounded_sm()
601 .border_color(cx.theme().colors().element_selected)
602 .border_1()
603 .when(!self.has_open_menu(message_id), |el| {
604 el.visible_on_hover("")
605 })
606 .bg(cx.theme().colors().element_background)
607 .when_some(message_id, |el, message_id| {
608 el.child(
609 self.render_popover_button(
610 cx,
611 div()
612 .id("reply")
613 .child(
614 IconButton::new(("reply", message_id), IconName::ReplyArrowRight)
615 .on_click(cx.listener(move |this, _, window, cx| {
616 this.cancel_edit_message(cx);
617
618 this.message_editor.update(cx, |editor, cx| {
619 editor.set_reply_to_message_id(message_id);
620 window.focus(&editor.focus_handle(cx));
621 })
622 })),
623 )
624 .tooltip(Tooltip::text("Reply")),
625 ),
626 )
627 })
628 .when_some(message_id, |el, message_id| {
629 el.when(can_edit_message, |el| {
630 el.child(
631 self.render_popover_button(
632 cx,
633 div()
634 .id("edit")
635 .child(
636 IconButton::new(("edit", message_id), IconName::Pencil)
637 .on_click(cx.listener(move |this, _, window, cx| {
638 this.message_editor.update(cx, |editor, cx| {
639 editor.clear_reply_to_message_id();
640
641 let message = this
642 .active_chat()
643 .and_then(|active_chat| {
644 active_chat
645 .read(cx)
646 .find_loaded_message(message_id)
647 })
648 .cloned();
649
650 if let Some(message) = message {
651 let buffer = editor
652 .editor
653 .read(cx)
654 .buffer()
655 .read(cx)
656 .as_singleton()
657 .expect("message editor must be singleton");
658
659 buffer.update(cx, |buffer, cx| {
660 buffer.set_text(message.body.clone(), cx)
661 });
662
663 editor.set_edit_message_id(message_id);
664 editor.focus_handle(cx).focus(window);
665 }
666 })
667 })),
668 )
669 .tooltip(Tooltip::text("Edit")),
670 ),
671 )
672 })
673 })
674 .when_some(message_id, |el, message_id| {
675 let this = cx.entity();
676
677 el.child(
678 self.render_popover_button(
679 cx,
680 div()
681 .child(
682 PopoverMenu::new(("menu", message_id))
683 .trigger(IconButton::new(
684 ("trigger", message_id),
685 IconName::Ellipsis,
686 ))
687 .menu(move |window, cx| {
688 Some(Self::render_message_menu(
689 &this,
690 message_id,
691 can_delete_message,
692 window,
693 cx,
694 ))
695 }),
696 )
697 .id("more")
698 .tooltip(Tooltip::text("More")),
699 ),
700 )
701 })
702 }
703
704 fn render_message_menu(
705 this: &Entity<Self>,
706 message_id: u64,
707 can_delete_message: bool,
708 window: &mut Window,
709 cx: &mut App,
710 ) -> Entity<ContextMenu> {
711 let menu = {
712 ContextMenu::build(window, cx, move |menu, window, _| {
713 menu.entry(
714 "Copy message text",
715 None,
716 window.handler_for(this, move |this, _, cx| {
717 if let Some(message) = this.active_chat().and_then(|active_chat| {
718 active_chat.read(cx).find_loaded_message(message_id)
719 }) {
720 let text = message.body.clone();
721 cx.write_to_clipboard(ClipboardItem::new_string(text))
722 }
723 }),
724 )
725 .when(can_delete_message, |menu| {
726 menu.entry(
727 "Delete message",
728 None,
729 window.handler_for(this, move |this, _, cx| {
730 this.remove_message(message_id, cx)
731 }),
732 )
733 })
734 })
735 };
736 this.update(cx, |this, cx| {
737 let subscription = cx.subscribe_in(
738 &menu,
739 window,
740 |this: &mut Self, _, _: &DismissEvent, _, _| {
741 this.open_context_menu = None;
742 },
743 );
744 this.open_context_menu = Some((message_id, subscription));
745 });
746 menu
747 }
748
749 fn render_markdown_with_mentions(
750 language_registry: &Arc<LanguageRegistry>,
751 current_user_id: u64,
752 message: &channel::ChannelMessage,
753 local_timezone: UtcOffset,
754 cx: &App,
755 ) -> RichText {
756 let mentions = message
757 .mentions
758 .iter()
759 .map(|(range, user_id)| rich_text::Mention {
760 range: range.clone(),
761 is_self_mention: *user_id == current_user_id,
762 })
763 .collect::<Vec<_>>();
764
765 const MESSAGE_EDITED: &str = " (edited)";
766
767 let mut body = message.body.clone();
768
769 if message.edited_at.is_some() {
770 body.push_str(MESSAGE_EDITED);
771 }
772
773 let mut rich_text = RichText::new(body, &mentions, language_registry);
774
775 if message.edited_at.is_some() {
776 let range = (rich_text.text.len() - MESSAGE_EDITED.len())..rich_text.text.len();
777 rich_text.highlights.push((
778 range.clone(),
779 Highlight::Highlight(HighlightStyle {
780 color: Some(cx.theme().colors().text_muted),
781 ..Default::default()
782 }),
783 ));
784
785 if let Some(edit_timestamp) = message.edited_at {
786 let edit_timestamp_text = time_format::format_localized_timestamp(
787 edit_timestamp,
788 OffsetDateTime::now_utc(),
789 local_timezone,
790 time_format::TimestampFormat::Absolute,
791 );
792
793 rich_text.custom_ranges.push(range);
794 rich_text.set_tooltip_builder_for_custom_ranges(move |_, _, _, cx| {
795 Some(Tooltip::simple(edit_timestamp_text.clone(), cx))
796 })
797 }
798 }
799 rich_text
800 }
801
802 fn send(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
803 if let Some((chat, _)) = self.active_chat.as_ref() {
804 let message = self
805 .message_editor
806 .update(cx, |editor, cx| editor.take_message(window, cx));
807
808 if let Some(id) = self.message_editor.read(cx).edit_message_id() {
809 self.message_editor.update(cx, |editor, _| {
810 editor.clear_edit_message_id();
811 });
812
813 if let Some(task) = chat
814 .update(cx, |chat, cx| chat.update_message(id, message, cx))
815 .log_err()
816 {
817 task.detach();
818 }
819 } else if let Some(task) = chat
820 .update(cx, |chat, cx| chat.send_message(message, cx))
821 .log_err()
822 {
823 task.detach();
824 }
825 }
826 }
827
828 fn remove_message(&mut self, id: u64, cx: &mut Context<Self>) {
829 if let Some((chat, _)) = self.active_chat.as_ref() {
830 chat.update(cx, |chat, cx| chat.remove_message(id, cx).detach())
831 }
832 }
833
834 fn load_more_messages(&mut self, cx: &mut Context<Self>) {
835 if let Some((chat, _)) = self.active_chat.as_ref() {
836 chat.update(cx, |channel, cx| {
837 if let Some(task) = channel.load_more_messages(cx) {
838 task.detach();
839 }
840 })
841 }
842 }
843
844 pub fn select_channel(
845 &mut self,
846 selected_channel_id: ChannelId,
847 scroll_to_message_id: Option<u64>,
848 cx: &mut Context<ChatPanel>,
849 ) -> Task<Result<()>> {
850 let open_chat = self
851 .active_chat
852 .as_ref()
853 .and_then(|(chat, _)| {
854 (chat.read(cx).channel_id == selected_channel_id)
855 .then(|| Task::ready(anyhow::Ok(chat.clone())))
856 })
857 .unwrap_or_else(|| {
858 self.channel_store.update(cx, |store, cx| {
859 store.open_channel_chat(selected_channel_id, cx)
860 })
861 });
862
863 cx.spawn(async move |this, cx| {
864 let chat = open_chat.await?;
865 let highlight_message_id = scroll_to_message_id;
866 let scroll_to_message_id = this.update(cx, |this, cx| {
867 this.set_active_chat(chat.clone(), cx);
868
869 scroll_to_message_id.or(this.last_acknowledged_message_id)
870 })?;
871
872 if let Some(message_id) = scroll_to_message_id
873 && let Some(item_ix) =
874 ChannelChat::load_history_since_message(chat.clone(), message_id, cx.clone())
875 .await
876 {
877 this.update(cx, |this, cx| {
878 if let Some(highlight_message_id) = highlight_message_id {
879 let task = cx.spawn(async move |this, cx| {
880 cx.background_executor().timer(Duration::from_secs(2)).await;
881 this.update(cx, |this, cx| {
882 this.highlighted_message.take();
883 cx.notify();
884 })
885 .ok();
886 });
887
888 this.highlighted_message = Some((highlight_message_id, task));
889 }
890
891 if this.active_chat.as_ref().map_or(false, |(c, _)| *c == chat) {
892 this.message_list.scroll_to(ListOffset {
893 item_ix,
894 offset_in_item: px(0.0),
895 });
896 cx.notify();
897 }
898 })?;
899 }
900
901 Ok(())
902 })
903 }
904
905 fn close_reply_preview(&mut self, cx: &mut Context<Self>) {
906 self.message_editor
907 .update(cx, |editor, _| editor.clear_reply_to_message_id());
908 }
909
910 fn cancel_edit_message(&mut self, cx: &mut Context<Self>) {
911 self.message_editor.update(cx, |editor, cx| {
912 // only clear the editor input if we were editing a message
913 if editor.edit_message_id().is_none() {
914 return;
915 }
916
917 editor.clear_edit_message_id();
918
919 let buffer = editor
920 .editor
921 .read(cx)
922 .buffer()
923 .read(cx)
924 .as_singleton()
925 .expect("message editor must be singleton");
926
927 buffer.update(cx, |buffer, cx| buffer.set_text("", cx));
928 });
929 }
930}
931
932impl Render for ChatPanel {
933 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
934 let channel_id = self
935 .active_chat
936 .as_ref()
937 .map(|(c, _)| c.read(cx).channel_id);
938 let message_editor = self.message_editor.read(cx);
939
940 let reply_to_message_id = message_editor.reply_to_message_id();
941 let edit_message_id = message_editor.edit_message_id();
942
943 v_flex()
944 .key_context("ChatPanel")
945 .track_focus(&self.focus_handle)
946 .size_full()
947 .on_action(cx.listener(Self::send))
948 .child(
949 h_flex().child(
950 TabBar::new("chat_header").child(
951 h_flex()
952 .w_full()
953 .h(Tab::container_height(cx))
954 .px_2()
955 .child(Label::new(
956 self.active_chat
957 .as_ref()
958 .and_then(|c| {
959 Some(format!("#{}", c.0.read(cx).channel(cx)?.name))
960 })
961 .unwrap_or("Chat".to_string()),
962 )),
963 ),
964 ),
965 )
966 .child(div().flex_grow().px_2().map(|this| {
967 if self.active_chat.is_some() {
968 this.child(
969 list(
970 self.message_list.clone(),
971 cx.processor(Self::render_message),
972 )
973 .size_full(),
974 )
975 } else {
976 this.child(
977 div()
978 .size_full()
979 .p_4()
980 .child(
981 Label::new("Select a channel to chat in.")
982 .size(LabelSize::Small)
983 .color(Color::Muted),
984 )
985 .child(
986 div().pt_1().w_full().items_center().child(
987 Button::new("toggle-collab", "Open")
988 .full_width()
989 .key_binding(KeyBinding::for_action(
990 &collab_panel::ToggleFocus,
991 window,
992 cx,
993 ))
994 .on_click(|_, window, cx| {
995 window.dispatch_action(
996 collab_panel::ToggleFocus.boxed_clone(),
997 cx,
998 )
999 }),
1000 ),
1001 ),
1002 )
1003 }
1004 }))
1005 .when(!self.is_scrolled_to_bottom, |el| {
1006 el.child(div().border_t_1().border_color(cx.theme().colors().border))
1007 })
1008 .when_some(edit_message_id, |el, _| {
1009 el.child(
1010 h_flex()
1011 .px_2()
1012 .text_ui_xs(cx)
1013 .justify_between()
1014 .border_t_1()
1015 .border_color(cx.theme().colors().border)
1016 .bg(cx.theme().colors().background)
1017 .child("Editing message")
1018 .child(
1019 IconButton::new("cancel-edit-message", IconName::Close)
1020 .shape(ui::IconButtonShape::Square)
1021 .tooltip(Tooltip::text("Cancel edit message"))
1022 .on_click(cx.listener(move |this, _, _, cx| {
1023 this.cancel_edit_message(cx);
1024 })),
1025 ),
1026 )
1027 })
1028 .when_some(reply_to_message_id, |el, reply_to_message_id| {
1029 let reply_message = self
1030 .active_chat()
1031 .and_then(|active_chat| {
1032 active_chat
1033 .read(cx)
1034 .find_loaded_message(reply_to_message_id)
1035 })
1036 .cloned();
1037
1038 el.when_some(reply_message, |el, reply_message| {
1039 let user_being_replied_to = reply_message.sender.clone();
1040
1041 el.child(
1042 h_flex()
1043 .when(!self.is_scrolled_to_bottom, |el| {
1044 el.border_t_1().border_color(cx.theme().colors().border)
1045 })
1046 .justify_between()
1047 .overflow_hidden()
1048 .items_start()
1049 .py_1()
1050 .px_2()
1051 .bg(cx.theme().colors().background)
1052 .child(
1053 div().flex_shrink().overflow_hidden().child(
1054 h_flex()
1055 .id(("reply-preview", reply_to_message_id))
1056 .child(Label::new("Replying to ").size(LabelSize::Small))
1057 .child(
1058 Label::new(format!(
1059 "@{}",
1060 user_being_replied_to.github_login
1061 ))
1062 .size(LabelSize::Small)
1063 .weight(FontWeight::BOLD),
1064 )
1065 .when_some(channel_id, |this, channel_id| {
1066 this.cursor_pointer().on_click(cx.listener(
1067 move |chat_panel, _, _, cx| {
1068 chat_panel
1069 .select_channel(
1070 channel_id,
1071 reply_to_message_id.into(),
1072 cx,
1073 )
1074 .detach_and_log_err(cx)
1075 },
1076 ))
1077 }),
1078 ),
1079 )
1080 .child(
1081 IconButton::new("close-reply-preview", IconName::Close)
1082 .shape(ui::IconButtonShape::Square)
1083 .tooltip(Tooltip::text("Close reply"))
1084 .on_click(cx.listener(move |this, _, _, cx| {
1085 this.close_reply_preview(cx);
1086 })),
1087 ),
1088 )
1089 })
1090 })
1091 .children(
1092 Some(
1093 h_flex()
1094 .p_2()
1095 .on_action(cx.listener(|this, _: &actions::Cancel, _, cx| {
1096 this.cancel_edit_message(cx);
1097 this.close_reply_preview(cx);
1098 }))
1099 .map(|el| el.child(self.message_editor.clone())),
1100 )
1101 .filter(|_| self.active_chat.is_some()),
1102 )
1103 .into_any()
1104 }
1105}
1106
1107impl Focusable for ChatPanel {
1108 fn focus_handle(&self, cx: &App) -> gpui::FocusHandle {
1109 if self.active_chat.is_some() {
1110 self.message_editor.read(cx).focus_handle(cx)
1111 } else {
1112 self.focus_handle.clone()
1113 }
1114 }
1115}
1116
1117impl Panel for ChatPanel {
1118 fn position(&self, _: &Window, cx: &App) -> DockPosition {
1119 ChatPanelSettings::get_global(cx).dock
1120 }
1121
1122 fn position_is_valid(&self, position: DockPosition) -> bool {
1123 matches!(position, DockPosition::Left | DockPosition::Right)
1124 }
1125
1126 fn set_position(&mut self, position: DockPosition, _: &mut Window, cx: &mut Context<Self>) {
1127 settings::update_settings_file::<ChatPanelSettings>(
1128 self.fs.clone(),
1129 cx,
1130 move |settings, _| settings.dock = Some(position),
1131 );
1132 }
1133
1134 fn size(&self, _: &Window, cx: &App) -> Pixels {
1135 self.width
1136 .unwrap_or_else(|| ChatPanelSettings::get_global(cx).default_width)
1137 }
1138
1139 fn set_size(&mut self, size: Option<Pixels>, _: &mut Window, cx: &mut Context<Self>) {
1140 self.width = size;
1141 self.serialize(cx);
1142 cx.notify();
1143 }
1144
1145 fn set_active(&mut self, active: bool, _: &mut Window, cx: &mut Context<Self>) {
1146 self.active = active;
1147 if active {
1148 self.acknowledge_last_message(cx);
1149 }
1150 }
1151
1152 fn persistent_name() -> &'static str {
1153 "ChatPanel"
1154 }
1155
1156 fn icon(&self, _window: &Window, cx: &App) -> Option<ui::IconName> {
1157 self.enabled(cx).then(|| ui::IconName::Chat)
1158 }
1159
1160 fn icon_tooltip(&self, _: &Window, _: &App) -> Option<&'static str> {
1161 Some("Chat Panel")
1162 }
1163
1164 fn toggle_action(&self) -> Box<dyn gpui::Action> {
1165 Box::new(ToggleFocus)
1166 }
1167
1168 fn starts_open(&self, _: &Window, cx: &App) -> bool {
1169 ActiveCall::global(cx)
1170 .read(cx)
1171 .room()
1172 .is_some_and(|room| room.read(cx).contains_guests())
1173 }
1174
1175 fn activation_priority(&self) -> u32 {
1176 7
1177 }
1178
1179 fn enabled(&self, cx: &App) -> bool {
1180 match ChatPanelSettings::get_global(cx).button {
1181 ChatPanelButton::Never => false,
1182 ChatPanelButton::Always => true,
1183 ChatPanelButton::WhenInCall => {
1184 let is_in_call = ActiveCall::global(cx)
1185 .read(cx)
1186 .room()
1187 .map_or(false, |room| room.read(cx).contains_guests());
1188
1189 self.active || is_in_call
1190 }
1191 }
1192 }
1193}
1194
1195impl EventEmitter<PanelEvent> for ChatPanel {}
1196
1197#[cfg(test)]
1198mod tests {
1199 use super::*;
1200 use gpui::HighlightStyle;
1201 use pretty_assertions::assert_eq;
1202 use rich_text::Highlight;
1203 use time::OffsetDateTime;
1204 use util::test::marked_text_ranges;
1205
1206 #[gpui::test]
1207 fn test_render_markdown_with_mentions(cx: &mut App) {
1208 let language_registry = Arc::new(LanguageRegistry::test(cx.background_executor().clone()));
1209 let (body, ranges) = marked_text_ranges("*hi*, «@abc», let's **call** «@fgh»", false);
1210 let message = channel::ChannelMessage {
1211 id: ChannelMessageId::Saved(0),
1212 body,
1213 timestamp: OffsetDateTime::now_utc(),
1214 sender: Arc::new(client::User {
1215 github_login: "fgh".into(),
1216 avatar_uri: "avatar_fgh".into(),
1217 id: 103,
1218 name: None,
1219 }),
1220 nonce: 5,
1221 mentions: vec![(ranges[0].clone(), 101), (ranges[1].clone(), 102)],
1222 reply_to_message_id: None,
1223 edited_at: None,
1224 };
1225
1226 let message = ChatPanel::render_markdown_with_mentions(
1227 &language_registry,
1228 102,
1229 &message,
1230 UtcOffset::UTC,
1231 cx,
1232 );
1233
1234 // Note that the "'" was replaced with ’ due to smart punctuation.
1235 let (body, ranges) = marked_text_ranges("«hi», «@abc», let’s «call» «@fgh»", false);
1236 assert_eq!(message.text, body);
1237 assert_eq!(
1238 message.highlights,
1239 vec![
1240 (
1241 ranges[0].clone(),
1242 HighlightStyle {
1243 font_style: Some(gpui::FontStyle::Italic),
1244 ..Default::default()
1245 }
1246 .into()
1247 ),
1248 (ranges[1].clone(), Highlight::Mention),
1249 (
1250 ranges[2].clone(),
1251 HighlightStyle {
1252 font_weight: Some(gpui::FontWeight::BOLD),
1253 ..Default::default()
1254 }
1255 .into()
1256 ),
1257 (ranges[3].clone(), Highlight::SelfMention)
1258 ]
1259 );
1260 }
1261
1262 #[gpui::test]
1263 fn test_render_markdown_with_auto_detect_links(cx: &mut App) {
1264 let language_registry = Arc::new(LanguageRegistry::test(cx.background_executor().clone()));
1265 let message = channel::ChannelMessage {
1266 id: ChannelMessageId::Saved(0),
1267 body: "Here is a link https://zed.dev to zeds website".to_string(),
1268 timestamp: OffsetDateTime::now_utc(),
1269 sender: Arc::new(client::User {
1270 github_login: "fgh".into(),
1271 avatar_uri: "avatar_fgh".into(),
1272 id: 103,
1273 name: None,
1274 }),
1275 nonce: 5,
1276 mentions: Vec::new(),
1277 reply_to_message_id: None,
1278 edited_at: None,
1279 };
1280
1281 let message = ChatPanel::render_markdown_with_mentions(
1282 &language_registry,
1283 102,
1284 &message,
1285 UtcOffset::UTC,
1286 cx,
1287 );
1288
1289 // Note that the "'" was replaced with ’ due to smart punctuation.
1290 let (body, ranges) =
1291 marked_text_ranges("Here is a link «https://zed.dev» to zeds website", false);
1292 assert_eq!(message.text, body);
1293 assert_eq!(1, ranges.len());
1294 assert_eq!(
1295 message.highlights,
1296 vec![(
1297 ranges[0].clone(),
1298 HighlightStyle {
1299 underline: Some(gpui::UnderlineStyle {
1300 thickness: 1.0.into(),
1301 ..Default::default()
1302 }),
1303 ..Default::default()
1304 }
1305 .into()
1306 ),]
1307 );
1308 }
1309
1310 #[gpui::test]
1311 fn test_render_markdown_with_auto_detect_links_and_additional_formatting(cx: &mut App) {
1312 let language_registry = Arc::new(LanguageRegistry::test(cx.background_executor().clone()));
1313 let message = channel::ChannelMessage {
1314 id: ChannelMessageId::Saved(0),
1315 body: "**Here is a link https://zed.dev to zeds website**".to_string(),
1316 timestamp: OffsetDateTime::now_utc(),
1317 sender: Arc::new(client::User {
1318 github_login: "fgh".into(),
1319 avatar_uri: "avatar_fgh".into(),
1320 id: 103,
1321 name: None,
1322 }),
1323 nonce: 5,
1324 mentions: Vec::new(),
1325 reply_to_message_id: None,
1326 edited_at: None,
1327 };
1328
1329 let message = ChatPanel::render_markdown_with_mentions(
1330 &language_registry,
1331 102,
1332 &message,
1333 UtcOffset::UTC,
1334 cx,
1335 );
1336
1337 // Note that the "'" was replaced with ’ due to smart punctuation.
1338 let (body, ranges) = marked_text_ranges(
1339 "«Here is a link »«https://zed.dev»« to zeds website»",
1340 false,
1341 );
1342 assert_eq!(message.text, body);
1343 assert_eq!(3, ranges.len());
1344 assert_eq!(
1345 message.highlights,
1346 vec![
1347 (
1348 ranges[0].clone(),
1349 HighlightStyle {
1350 font_weight: Some(gpui::FontWeight::BOLD),
1351 ..Default::default()
1352 }
1353 .into()
1354 ),
1355 (
1356 ranges[1].clone(),
1357 HighlightStyle {
1358 font_weight: Some(gpui::FontWeight::BOLD),
1359 underline: Some(gpui::UnderlineStyle {
1360 thickness: 1.0.into(),
1361 ..Default::default()
1362 }),
1363 ..Default::default()
1364 }
1365 .into()
1366 ),
1367 (
1368 ranges[2].clone(),
1369 HighlightStyle {
1370 font_weight: Some(gpui::FontWeight::BOLD),
1371 ..Default::default()
1372 }
1373 .into()
1374 ),
1375 ]
1376 );
1377 }
1378}