1use crate::{channel_view::ChannelView, is_channels_feature_enabled, ChatPanelSettings};
2use anyhow::Result;
3use call::ActiveCall;
4use channel::{ChannelChat, ChannelChatEvent, ChannelMessageId, ChannelStore};
5use client::Client;
6use collections::HashMap;
7use db::kvp::KEY_VALUE_STORE;
8use editor::Editor;
9use gpui::{
10 actions, div, list, prelude::*, px, serde_json, AnyElement, AppContext, AsyncWindowContext,
11 ClickEvent, Div, EventEmitter, FocusableView, ListOffset, ListScrollEvent, ListState, Model,
12 Render, SharedString, Subscription, Task, View, ViewContext, VisualContext, WeakView,
13};
14use language::LanguageRegistry;
15use menu::Confirm;
16use message_editor::MessageEditor;
17use project::Fs;
18use rich_text::RichText;
19use serde::{Deserialize, Serialize};
20use settings::{Settings, SettingsStore};
21use std::sync::Arc;
22use theme::ActiveTheme as _;
23use time::{OffsetDateTime, UtcOffset};
24use ui::{
25 h_stack, prelude::WindowContext, v_stack, Avatar, Button, ButtonCommon as _, Clickable, Icon,
26 IconButton, Label, 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: &'static str = "ChatPanel";
38
39pub struct ChatPanel {
40 client: Arc<Client>,
41 channel_store: Model<ChannelStore>,
42 languages: Arc<LanguageRegistry>,
43 message_list: ListState,
44 active_chat: Option<(Model<ChannelChat>, Subscription)>,
45 input_editor: View<MessageEditor>,
46 local_timezone: UtcOffset,
47 fs: Arc<dyn Fs>,
48 width: Option<f32>,
49 active: bool,
50 pending_serialization: Task<Option<()>>,
51 subscriptions: Vec<gpui::Subscription>,
52 workspace: WeakView<Workspace>,
53 is_scrolled_to_bottom: bool,
54 markdown_data: HashMap<ChannelMessageId, RichText>,
55}
56
57#[derive(Serialize, Deserialize)]
58struct SerializedChatPanel {
59 width: Option<f32>,
60}
61
62#[derive(Debug)]
63pub enum Event {
64 DockPositionChanged,
65 Focus,
66 Dismissed,
67}
68
69actions!(ToggleFocus, OpenChannelNotes, JoinCall);
70
71// pub fn init(cx: &mut AppContext) {
72// cx.add_action(ChatPanel::send);
73// cx.add_action(ChatPanel::open_notes);
74// cx.add_action(ChatPanel::join_call);
75// }
76
77impl ChatPanel {
78 pub fn new(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) -> View<Self> {
79 let fs = workspace.app_state().fs.clone();
80 let client = workspace.app_state().client.clone();
81 let channel_store = ChannelStore::global(cx);
82 let languages = workspace.app_state().languages.clone();
83
84 let input_editor = cx.build_view(|cx| {
85 MessageEditor::new(
86 languages.clone(),
87 channel_store.clone(),
88 cx.build_view(|cx| Editor::auto_height(4, cx)),
89 cx,
90 )
91 });
92
93 let workspace_handle = workspace.weak_handle();
94
95 // let channel_select = cx.build_view(|cx| {
96 // let channel_store = channel_store.clone();
97 // let workspace = workspace_handle.clone();
98 // Select::new(0, cx, {
99 // move |ix, item_type, is_hovered, cx| {
100 // Self::render_channel_name(
101 // &channel_store,
102 // ix,
103 // item_type,
104 // is_hovered,
105 // workspace,
106 // cx,
107 // )
108 // }
109 // })
110 // .with_style(move |cx| {
111 // let style = &cx.theme().chat_panel.channel_select;
112 // SelectStyle {
113 // header: Default::default(),
114 // menu: style.menu,
115 // }
116 // })
117 // });
118
119 cx.build_view(|cx| {
120 let view: View<ChatPanel> = cx.view().clone();
121 let message_list =
122 ListState::new(0, gpui::ListAlignment::Bottom, px(1000.), move |ix, cx| {
123 view.update(cx, |view, cx| view.render_message(ix, cx))
124 });
125
126 message_list.set_scroll_handler(cx.listener(|this, event: &ListScrollEvent, cx| {
127 if event.visible_range.start < MESSAGE_LOADING_THRESHOLD {
128 this.load_more_messages(cx);
129 }
130 this.is_scrolled_to_bottom = event.visible_range.end == event.count;
131 }));
132
133 let mut this = Self {
134 fs,
135 client,
136 channel_store,
137 languages,
138 message_list,
139 active_chat: Default::default(),
140 pending_serialization: Task::ready(None),
141 input_editor,
142 local_timezone: cx.local_timezone(),
143 subscriptions: Vec::new(),
144 workspace: workspace_handle,
145 is_scrolled_to_bottom: true,
146 active: false,
147 width: None,
148 markdown_data: Default::default(),
149 };
150
151 let mut old_dock_position = this.position(cx);
152 this.subscriptions.push(cx.observe_global::<SettingsStore>(
153 move |this: &mut Self, cx| {
154 let new_dock_position = this.position(cx);
155 if new_dock_position != old_dock_position {
156 old_dock_position = new_dock_position;
157 cx.emit(Event::DockPositionChanged);
158 }
159 cx.notify();
160 },
161 ));
162
163 // this.update_channel_count(cx);
164 // cx.observe(&this.channel_store, |this, _, cx| {
165 // this.update_channel_count(cx)
166 // })
167 // .detach();
168
169 // cx.observe(&this.channel_select, |this, channel_select, cx| {
170 // let selected_ix = channel_select.read(cx).selected_index();
171
172 // let selected_channel_id = this
173 // .channel_store
174 // .read(cx)
175 // .channel_at(selected_ix)
176 // .map(|e| e.id);
177 // if let Some(selected_channel_id) = selected_channel_id {
178 // this.select_channel(selected_channel_id, None, cx)
179 // .detach_and_log_err(cx);
180 // }
181 // })
182 // .detach();
183
184 this
185 })
186 }
187
188 pub fn is_scrolled_to_bottom(&self) -> bool {
189 self.is_scrolled_to_bottom
190 }
191
192 pub fn active_chat(&self) -> Option<Model<ChannelChat>> {
193 self.active_chat.as_ref().map(|(chat, _)| chat.clone())
194 }
195
196 pub fn load(
197 workspace: WeakView<Workspace>,
198 cx: AsyncWindowContext,
199 ) -> Task<Result<View<Self>>> {
200 cx.spawn(|mut cx| async move {
201 let serialized_panel = if let Some(panel) = cx
202 .background_executor()
203 .spawn(async move { KEY_VALUE_STORE.read_kvp(CHAT_PANEL_KEY) })
204 .await
205 .log_err()
206 .flatten()
207 {
208 Some(serde_json::from_str::<SerializedChatPanel>(&panel)?)
209 } else {
210 None
211 };
212
213 workspace.update(&mut cx, |workspace, cx| {
214 let panel = Self::new(workspace, cx);
215 if let Some(serialized_panel) = serialized_panel {
216 panel.update(cx, |panel, cx| {
217 panel.width = serialized_panel.width;
218 cx.notify();
219 });
220 }
221 panel
222 })
223 })
224 }
225
226 fn serialize(&mut self, cx: &mut ViewContext<Self>) {
227 let width = self.width;
228 self.pending_serialization = cx.background_executor().spawn(
229 async move {
230 KEY_VALUE_STORE
231 .write_kvp(
232 CHAT_PANEL_KEY.into(),
233 serde_json::to_string(&SerializedChatPanel { width })?,
234 )
235 .await?;
236 anyhow::Ok(())
237 }
238 .log_err(),
239 );
240 }
241
242 // fn update_channel_count(&mut self, cx: &mut ViewContext<Self>) {
243 // let channel_count = self.channel_store.read(cx).channel_count();
244 // self.channel_select.update(cx, |select, cx| {
245 // select.set_item_count(channel_count, cx);
246 // });
247 // }
248
249 fn set_active_chat(&mut self, chat: Model<ChannelChat>, cx: &mut ViewContext<Self>) {
250 if self.active_chat.as_ref().map(|e| &e.0) != Some(&chat) {
251 let channel_id = chat.read(cx).channel_id;
252 {
253 self.markdown_data.clear();
254 let chat = chat.read(cx);
255 self.message_list.reset(chat.message_count());
256
257 let channel_name = chat.channel(cx).map(|channel| channel.name.clone());
258 self.input_editor.update(cx, |editor, cx| {
259 editor.set_channel(channel_id, channel_name, cx);
260 });
261 };
262 let subscription = cx.subscribe(&chat, Self::channel_did_change);
263 self.active_chat = Some((chat, subscription));
264 self.acknowledge_last_message(cx);
265 // self.channel_select.update(cx, |select, cx| {
266 // if let Some(ix) = self.channel_store.read(cx).index_of_channel(channel_id) {
267 // select.set_selected_index(ix, cx);
268 // }
269 // });
270 cx.notify();
271 }
272 }
273
274 fn channel_did_change(
275 &mut self,
276 _: Model<ChannelChat>,
277 event: &ChannelChatEvent,
278 cx: &mut ViewContext<Self>,
279 ) {
280 match event {
281 ChannelChatEvent::MessagesUpdated {
282 old_range,
283 new_count,
284 } => {
285 self.message_list.splice(old_range.clone(), *new_count);
286 if self.active {
287 self.acknowledge_last_message(cx);
288 }
289 }
290 ChannelChatEvent::NewMessage {
291 channel_id,
292 message_id,
293 } => {
294 if !self.active {
295 self.channel_store.update(cx, |store, cx| {
296 store.new_message(*channel_id, *message_id, cx)
297 })
298 }
299 }
300 }
301 cx.notify();
302 }
303
304 fn acknowledge_last_message(&mut self, cx: &mut ViewContext<Self>) {
305 if self.active && self.is_scrolled_to_bottom {
306 if let Some((chat, _)) = &self.active_chat {
307 chat.update(cx, |chat, cx| {
308 chat.acknowledge_last_message(cx);
309 });
310 }
311 }
312 }
313
314 fn render_channel(&self, cx: &mut ViewContext<Self>) -> AnyElement {
315 v_stack()
316 .full()
317 .on_action(cx.listener(Self::send))
318 .child(
319 h_stack()
320 .w_full()
321 .h_7()
322 .justify_between()
323 .z_index(1)
324 .bg(cx.theme().colors().background)
325 .border()
326 .border_color(gpui::red())
327 .child(Label::new(
328 self.active_chat
329 .as_ref()
330 .and_then(|c| Some(c.0.read(cx).channel(cx)?.name.clone()))
331 .unwrap_or_default(),
332 ))
333 .child(
334 h_stack()
335 .child(
336 IconButton::new("notes", Icon::File)
337 .on_click(cx.listener(Self::open_notes))
338 .tooltip(|cx| Tooltip::text("Open notes", cx)),
339 )
340 .child(
341 IconButton::new("call", Icon::AudioOn)
342 .on_click(cx.listener(Self::join_call))
343 .tooltip(|cx| Tooltip::text("Join call", cx)),
344 ),
345 ),
346 )
347 .child(div().grow().child(self.render_active_channel_messages(cx)))
348 .child(
349 div()
350 .z_index(1)
351 .p_2()
352 .bg(cx.theme().colors().background)
353 .child(self.input_editor.clone()),
354 )
355 .into_any()
356 }
357
358 fn render_active_channel_messages(&self, _cx: &mut ViewContext<Self>) -> AnyElement {
359 if self.active_chat.is_some() {
360 list(self.message_list.clone()).full().into_any_element()
361 } else {
362 div().into_any_element()
363 }
364 }
365
366 fn render_message(&mut self, ix: usize, cx: &mut ViewContext<Self>) -> AnyElement {
367 let active_chat = &self.active_chat.as_ref().unwrap().0;
368 let (message, is_continuation, is_admin) = active_chat.update(cx, |active_chat, cx| {
369 let is_admin = self
370 .channel_store
371 .read(cx)
372 .is_channel_admin(active_chat.channel_id);
373
374 let last_message = active_chat.message(ix.saturating_sub(1));
375 let this_message = active_chat.message(ix).clone();
376 let is_continuation = last_message.id != this_message.id
377 && this_message.sender.id == last_message.sender.id;
378
379 if let ChannelMessageId::Saved(id) = this_message.id {
380 if this_message
381 .mentions
382 .iter()
383 .any(|(_, user_id)| Some(*user_id) == self.client.user_id())
384 {
385 active_chat.acknowledge_message(id);
386 }
387 }
388
389 (this_message, is_continuation, is_admin)
390 });
391
392 let _is_pending = message.is_pending();
393 let text = self.markdown_data.entry(message.id).or_insert_with(|| {
394 Self::render_markdown_with_mentions(&self.languages, self.client.id(), &message)
395 });
396
397 let now = OffsetDateTime::now_utc();
398
399 let belongs_to_user = Some(message.sender.id) == self.client.user_id();
400 let message_id_to_remove = if let (ChannelMessageId::Saved(id), true) =
401 (message.id, belongs_to_user || is_admin)
402 {
403 Some(id)
404 } else {
405 None
406 };
407
408 // todo!("render the text with markdown formatting")
409 if is_continuation {
410 h_stack()
411 .child(SharedString::from(text.text.clone()))
412 .child(render_remove(message_id_to_remove, cx))
413 .mb_1()
414 .into_any()
415 } else {
416 v_stack()
417 .child(
418 h_stack()
419 .children(
420 message
421 .sender
422 .avatar
423 .clone()
424 .map(|avatar| Avatar::data(avatar)),
425 )
426 .child(Label::new(message.sender.github_login.clone()))
427 .child(Label::new(format_timestamp(
428 message.timestamp,
429 now,
430 self.local_timezone,
431 )))
432 .child(render_remove(message_id_to_remove, cx)),
433 )
434 .child(
435 h_stack()
436 .child(SharedString::from(text.text.clone()))
437 .child(render_remove(None, cx)),
438 )
439 .mb_1()
440 .into_any()
441 }
442 }
443
444 fn render_markdown_with_mentions(
445 language_registry: &Arc<LanguageRegistry>,
446 current_user_id: u64,
447 message: &channel::ChannelMessage,
448 ) -> RichText {
449 let mentions = message
450 .mentions
451 .iter()
452 .map(|(range, user_id)| rich_text::Mention {
453 range: range.clone(),
454 is_self_mention: *user_id == current_user_id,
455 })
456 .collect::<Vec<_>>();
457
458 rich_text::render_markdown(message.body.clone(), &mentions, language_registry, None)
459 }
460
461 // fn render_channel_name(
462 // channel_store: &Model<ChannelStore>,
463 // ix: usize,
464 // item_type: ItemType,
465 // is_hovered: bool,
466 // workspace: WeakView<Workspace>,
467 // cx: &mut ViewContext<Select>,
468 // ) -> AnyElement<Select> {
469 // let theme = theme::current(cx);
470 // let tooltip_style = &theme.tooltip;
471 // let theme = &theme.chat_panel;
472 // let style = match (&item_type, is_hovered) {
473 // (ItemType::Header, _) => &theme.channel_select.header,
474 // (ItemType::Selected, _) => &theme.channel_select.active_item,
475 // (ItemType::Unselected, false) => &theme.channel_select.item,
476 // (ItemType::Unselected, true) => &theme.channel_select.hovered_item,
477 // };
478
479 // let channel = &channel_store.read(cx).channel_at(ix).unwrap();
480 // let channel_id = channel.id;
481
482 // let mut row = Flex::row()
483 // .with_child(
484 // Label::new("#".to_string(), style.hash.text.clone())
485 // .contained()
486 // .with_style(style.hash.container),
487 // )
488 // .with_child(Label::new(channel.name.clone(), style.name.clone()));
489
490 // if matches!(item_type, ItemType::Header) {
491 // row.add_children([
492 // MouseEventHandler::new::<OpenChannelNotes, _>(0, cx, |mouse_state, _| {
493 // render_icon_button(theme.icon_button.style_for(mouse_state), "icons/file.svg")
494 // })
495 // .on_click(MouseButton::Left, move |_, _, cx| {
496 // if let Some(workspace) = workspace.upgrade(cx) {
497 // ChannelView::open(channel_id, workspace, cx).detach();
498 // }
499 // })
500 // .with_tooltip::<OpenChannelNotes>(
501 // channel_id as usize,
502 // "Open Notes",
503 // Some(Box::new(OpenChannelNotes)),
504 // tooltip_style.clone(),
505 // cx,
506 // )
507 // .flex_float(),
508 // MouseEventHandler::new::<ActiveCall, _>(0, cx, |mouse_state, _| {
509 // render_icon_button(
510 // theme.icon_button.style_for(mouse_state),
511 // "icons/speaker-loud.svg",
512 // )
513 // })
514 // .on_click(MouseButton::Left, move |_, _, cx| {
515 // ActiveCall::global(cx)
516 // .update(cx, |call, cx| call.join_channel(channel_id, cx))
517 // .detach_and_log_err(cx);
518 // })
519 // .with_tooltip::<ActiveCall>(
520 // channel_id as usize,
521 // "Join Call",
522 // Some(Box::new(JoinCall)),
523 // tooltip_style.clone(),
524 // cx,
525 // )
526 // .flex_float(),
527 // ]);
528 // }
529
530 // row.align_children_center()
531 // .contained()
532 // .with_style(style.container)
533 // .into_any()
534 // }
535
536 fn render_sign_in_prompt(&self, cx: &mut ViewContext<Self>) -> AnyElement {
537 Button::new("sign-in", "Sign in to use chat")
538 .on_click(cx.listener(move |this, _, cx| {
539 let client = this.client.clone();
540 cx.spawn(|this, mut cx| async move {
541 if client
542 .authenticate_and_connect(true, &cx)
543 .log_err()
544 .await
545 .is_some()
546 {
547 this.update(&mut cx, |_, cx| {
548 cx.focus_self();
549 })
550 .ok();
551 }
552 })
553 .detach();
554 }))
555 .into_any_element()
556 }
557
558 fn send(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
559 if let Some((chat, _)) = self.active_chat.as_ref() {
560 let message = self
561 .input_editor
562 .update(cx, |editor, cx| editor.take_message(cx));
563
564 if let Some(task) = chat
565 .update(cx, |chat, cx| chat.send_message(message, cx))
566 .log_err()
567 {
568 task.detach();
569 }
570 }
571 }
572
573 fn remove_message(&mut self, id: u64, cx: &mut ViewContext<Self>) {
574 if let Some((chat, _)) = self.active_chat.as_ref() {
575 chat.update(cx, |chat, cx| chat.remove_message(id, cx).detach())
576 }
577 }
578
579 fn load_more_messages(&mut self, cx: &mut ViewContext<Self>) {
580 if let Some((chat, _)) = self.active_chat.as_ref() {
581 chat.update(cx, |channel, cx| {
582 if let Some(task) = channel.load_more_messages(cx) {
583 task.detach();
584 }
585 })
586 }
587 }
588
589 pub fn select_channel(
590 &mut self,
591 selected_channel_id: u64,
592 scroll_to_message_id: Option<u64>,
593 cx: &mut ViewContext<ChatPanel>,
594 ) -> Task<Result<()>> {
595 let open_chat = self
596 .active_chat
597 .as_ref()
598 .and_then(|(chat, _)| {
599 (chat.read(cx).channel_id == selected_channel_id)
600 .then(|| Task::ready(anyhow::Ok(chat.clone())))
601 })
602 .unwrap_or_else(|| {
603 self.channel_store.update(cx, |store, cx| {
604 store.open_channel_chat(selected_channel_id, cx)
605 })
606 });
607
608 cx.spawn(|this, mut cx| async move {
609 let chat = open_chat.await?;
610 this.update(&mut cx, |this, cx| {
611 this.set_active_chat(chat.clone(), cx);
612 })?;
613
614 if let Some(message_id) = scroll_to_message_id {
615 if let Some(item_ix) =
616 ChannelChat::load_history_since_message(chat.clone(), message_id, (*cx).clone())
617 .await
618 {
619 this.update(&mut cx, |this, cx| {
620 if this.active_chat.as_ref().map_or(false, |(c, _)| *c == chat) {
621 this.message_list.scroll_to(ListOffset {
622 item_ix,
623 offset_in_item: px(0.0),
624 });
625 cx.notify();
626 }
627 })?;
628 }
629 }
630
631 Ok(())
632 })
633 }
634
635 fn open_notes(&mut self, _: &ClickEvent, cx: &mut ViewContext<Self>) {
636 if let Some((chat, _)) = &self.active_chat {
637 let channel_id = chat.read(cx).channel_id;
638 if let Some(workspace) = self.workspace.upgrade() {
639 ChannelView::open(channel_id, workspace, cx).detach();
640 }
641 }
642 }
643
644 fn join_call(&mut self, _: &ClickEvent, cx: &mut ViewContext<Self>) {
645 if let Some((chat, _)) = &self.active_chat {
646 let channel_id = chat.read(cx).channel_id;
647 ActiveCall::global(cx)
648 .update(cx, |call, cx| call.join_channel(channel_id, cx))
649 .detach_and_log_err(cx);
650 }
651 }
652}
653
654fn render_remove(message_id_to_remove: Option<u64>, cx: &mut ViewContext<ChatPanel>) -> AnyElement {
655 if let Some(message_id) = message_id_to_remove {
656 IconButton::new(("remove", message_id), Icon::XCircle)
657 .on_click(cx.listener(move |this, _, cx| {
658 this.remove_message(message_id, cx);
659 }))
660 .into_any_element()
661 } else {
662 div().into_any_element()
663 }
664}
665
666impl EventEmitter<Event> for ChatPanel {}
667
668impl Render for ChatPanel {
669 type Element = Div;
670
671 fn render(&mut self, cx: &mut ViewContext<Self>) -> Self::Element {
672 div()
673 .full()
674 .child(if self.client.user_id().is_some() {
675 self.render_channel(cx)
676 } else {
677 self.render_sign_in_prompt(cx)
678 })
679 .min_w(px(150.))
680 }
681}
682
683impl FocusableView for ChatPanel {
684 fn focus_handle(&self, cx: &AppContext) -> gpui::FocusHandle {
685 self.input_editor.read(cx).focus_handle(cx)
686 }
687}
688
689impl Panel for ChatPanel {
690 fn position(&self, cx: &gpui::WindowContext) -> DockPosition {
691 ChatPanelSettings::get_global(cx).dock
692 }
693
694 fn position_is_valid(&self, position: DockPosition) -> bool {
695 matches!(position, DockPosition::Left | DockPosition::Right)
696 }
697
698 fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
699 settings::update_settings_file::<ChatPanelSettings>(self.fs.clone(), cx, move |settings| {
700 settings.dock = Some(position)
701 });
702 }
703
704 fn size(&self, cx: &gpui::WindowContext) -> f32 {
705 self.width
706 .unwrap_or_else(|| ChatPanelSettings::get_global(cx).default_width)
707 }
708
709 fn set_size(&mut self, size: Option<f32>, cx: &mut ViewContext<Self>) {
710 self.width = size;
711 self.serialize(cx);
712 cx.notify();
713 }
714
715 fn set_active(&mut self, active: bool, cx: &mut ViewContext<Self>) {
716 self.active = active;
717 if active {
718 self.acknowledge_last_message(cx);
719 if !is_channels_feature_enabled(cx) {
720 cx.emit(Event::Dismissed);
721 }
722 }
723 }
724
725 fn persistent_name() -> &'static str {
726 "ChatPanel"
727 }
728
729 fn icon(&self, _cx: &WindowContext) -> Option<ui::Icon> {
730 Some(ui::Icon::MessageBubbles)
731 }
732
733 fn toggle_action(&self) -> Box<dyn gpui::Action> {
734 Box::new(ToggleFocus)
735 }
736}
737
738impl EventEmitter<PanelEvent> for ChatPanel {}
739
740fn format_timestamp(
741 mut timestamp: OffsetDateTime,
742 mut now: OffsetDateTime,
743 local_timezone: UtcOffset,
744) -> String {
745 timestamp = timestamp.to_offset(local_timezone);
746 now = now.to_offset(local_timezone);
747
748 let today = now.date();
749 let date = timestamp.date();
750 let mut hour = timestamp.hour();
751 let mut part = "am";
752 if hour > 12 {
753 hour -= 12;
754 part = "pm";
755 }
756 if date == today {
757 format!("{:02}:{:02}{}", hour, timestamp.minute(), part)
758 } else if date.next_day() == Some(today) {
759 format!("yesterday at {:02}:{:02}{}", hour, timestamp.minute(), part)
760 } else {
761 format!("{:02}/{}/{}", date.month() as u32, date.day(), date.year())
762 }
763}
764
765// fn render_icon_button(style: &IconButton, svg_path: &'static str) -> impl Element<V> {
766// todo!()
767// // Svg::new(svg_path)
768// // .with_color(style.color)
769// // .constrained()
770// // .with_width(style.icon_width)
771// // .aligned()
772// // .constrained()
773// // .with_width(style.button_width)
774// // .with_height(style.button_width)
775// // .contained()
776// // .with_style(style.container)
777// }
778
779// #[cfg(test)]
780// mod tests {
781// use super::*;
782// use gpui::fonts::HighlightStyle;
783// use pretty_assertions::assert_eq;
784// use rich_text::{BackgroundKind, Highlight, RenderedRegion};
785// use util::test::marked_text_ranges;
786
787// #[gpui::test]
788// fn test_render_markdown_with_mentions() {
789// let language_registry = Arc::new(LanguageRegistry::test());
790// let (body, ranges) = marked_text_ranges("*hi*, «@abc», let's **call** «@fgh»", false);
791// let message = channel::ChannelMessage {
792// id: ChannelMessageId::Saved(0),
793// body,
794// timestamp: OffsetDateTime::now_utc(),
795// sender: Arc::new(client::User {
796// github_login: "fgh".into(),
797// avatar: None,
798// id: 103,
799// }),
800// nonce: 5,
801// mentions: vec![(ranges[0].clone(), 101), (ranges[1].clone(), 102)],
802// };
803
804// let message = ChatPanel::render_markdown_with_mentions(&language_registry, 102, &message);
805
806// // Note that the "'" was replaced with ’ due to smart punctuation.
807// let (body, ranges) = marked_text_ranges("«hi», «@abc», let’s «call» «@fgh»", false);
808// assert_eq!(message.text, body);
809// assert_eq!(
810// message.highlights,
811// vec![
812// (
813// ranges[0].clone(),
814// HighlightStyle {
815// italic: Some(true),
816// ..Default::default()
817// }
818// .into()
819// ),
820// (ranges[1].clone(), Highlight::Mention),
821// (
822// ranges[2].clone(),
823// HighlightStyle {
824// weight: Some(gpui::fonts::Weight::BOLD),
825// ..Default::default()
826// }
827// .into()
828// ),
829// (ranges[3].clone(), Highlight::SelfMention)
830// ]
831// );
832// assert_eq!(
833// message.regions,
834// vec![
835// RenderedRegion {
836// background_kind: Some(BackgroundKind::Mention),
837// link_url: None
838// },
839// RenderedRegion {
840// background_kind: Some(BackgroundKind::SelfMention),
841// link_url: None
842// },
843// ]
844// );
845// }
846// }