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