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