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