1use crate::{channel_view::ChannelView, ChatPanelSettings};
2use anyhow::Result;
3use call::ActiveCall;
4use channel::{ChannelChat, ChannelChatEvent, ChannelMessageId, ChannelStore};
5use client::Client;
6use db::kvp::KEY_VALUE_STORE;
7use editor::Editor;
8use feature_flags::{ChannelsAlpha, FeatureFlagAppExt};
9use gpui::{
10 actions,
11 elements::*,
12 platform::{CursorStyle, MouseButton},
13 serde_json,
14 views::{ItemType, Select, SelectStyle},
15 AnyViewHandle, AppContext, AsyncAppContext, Entity, ModelHandle, Subscription, Task, View,
16 ViewContext, ViewHandle, WeakViewHandle,
17};
18use language::language_settings::SoftWrap;
19use menu::Confirm;
20use project::Fs;
21use serde::{Deserialize, Serialize};
22use settings::SettingsStore;
23use std::sync::Arc;
24use theme::{IconButton, Theme};
25use time::{OffsetDateTime, UtcOffset};
26use util::{ResultExt, TryFutureExt};
27use workspace::{
28 dock::{DockPosition, Panel},
29 Workspace,
30};
31
32const MESSAGE_LOADING_THRESHOLD: usize = 50;
33const CHAT_PANEL_KEY: &'static str = "ChatPanel";
34
35pub struct ChatPanel {
36 client: Arc<Client>,
37 channel_store: ModelHandle<ChannelStore>,
38 active_chat: Option<(ModelHandle<ChannelChat>, Subscription)>,
39 message_list: ListState<ChatPanel>,
40 input_editor: ViewHandle<Editor>,
41 channel_select: ViewHandle<Select>,
42 local_timezone: UtcOffset,
43 fs: Arc<dyn Fs>,
44 width: Option<f32>,
45 active: bool,
46 pending_serialization: Task<Option<()>>,
47 subscriptions: Vec<gpui::Subscription>,
48 workspace: WeakViewHandle<Workspace>,
49 has_focus: bool,
50}
51
52#[derive(Serialize, Deserialize)]
53struct SerializedChatPanel {
54 width: Option<f32>,
55}
56
57#[derive(Debug)]
58pub enum Event {
59 DockPositionChanged,
60 Focus,
61 Dismissed,
62}
63
64actions!(
65 chat_panel,
66 [LoadMoreMessages, ToggleFocus, OpenChannelNotes, JoinCall]
67);
68
69pub fn init(cx: &mut AppContext) {
70 cx.add_action(ChatPanel::send);
71 cx.add_action(ChatPanel::load_more_messages);
72 cx.add_action(ChatPanel::open_notes);
73 cx.add_action(ChatPanel::join_call);
74}
75
76impl ChatPanel {
77 pub fn new(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) -> ViewHandle<Self> {
78 let fs = workspace.app_state().fs.clone();
79 let client = workspace.app_state().client.clone();
80 let channel_store = workspace.app_state().channel_store.clone();
81
82 let input_editor = cx.add_view(|cx| {
83 let mut editor = Editor::auto_height(
84 4,
85 Some(Arc::new(|theme| theme.chat_panel.input_editor.clone())),
86 cx,
87 );
88 editor.set_soft_wrap_mode(SoftWrap::EditorWidth, cx);
89 editor
90 });
91
92 let workspace_handle = workspace.weak_handle();
93
94 let channel_select = cx.add_view(|cx| {
95 let channel_store = channel_store.clone();
96 let workspace = workspace_handle.clone();
97 Select::new(0, cx, {
98 move |ix, item_type, is_hovered, cx| {
99 Self::render_channel_name(
100 &channel_store,
101 ix,
102 item_type,
103 is_hovered,
104 workspace,
105 cx,
106 )
107 }
108 })
109 .with_style(move |cx| {
110 let style = &theme::current(cx).chat_panel.channel_select;
111 SelectStyle {
112 header: Default::default(),
113 menu: style.menu,
114 }
115 })
116 });
117
118 let mut message_list =
119 ListState::<Self>::new(0, Orientation::Bottom, 1000., move |this, ix, cx| {
120 this.render_message(ix, cx)
121 });
122 message_list.set_scroll_handler(|visible_range, this, cx| {
123 if visible_range.start < MESSAGE_LOADING_THRESHOLD {
124 this.load_more_messages(&LoadMoreMessages, cx);
125 }
126 });
127
128 cx.add_view(|cx| {
129 let mut this = Self {
130 fs,
131 client,
132 channel_store,
133
134 active_chat: Default::default(),
135 pending_serialization: Task::ready(None),
136 message_list,
137 input_editor,
138 channel_select,
139 local_timezone: cx.platform().local_timezone(),
140 has_focus: false,
141 subscriptions: Vec::new(),
142 workspace: workspace_handle,
143 active: false,
144 width: None,
145 };
146
147 let mut old_dock_position = this.position(cx);
148 this.subscriptions
149 .push(
150 cx.observe_global::<SettingsStore, _>(move |this: &mut Self, cx| {
151 let new_dock_position = this.position(cx);
152 if new_dock_position != old_dock_position {
153 old_dock_position = new_dock_position;
154 cx.emit(Event::DockPositionChanged);
155 }
156 cx.notify();
157 }),
158 );
159
160 this.update_channel_count(cx);
161 cx.observe(&this.channel_store, |this, _, cx| {
162 this.update_channel_count(cx)
163 })
164 .detach();
165
166 cx.observe(&this.channel_select, |this, channel_select, cx| {
167 let selected_ix = channel_select.read(cx).selected_index();
168
169 let selected_channel_id = this
170 .channel_store
171 .read(cx)
172 .channel_at(selected_ix)
173 .map(|e| e.id);
174 if let Some(selected_channel_id) = selected_channel_id {
175 this.select_channel(selected_channel_id, cx)
176 .detach_and_log_err(cx);
177 }
178 })
179 .detach();
180
181 this
182 })
183 }
184
185 pub fn active_chat(&self) -> Option<ModelHandle<ChannelChat>> {
186 self.active_chat.as_ref().map(|(chat, _)| chat.clone())
187 }
188
189 pub fn load(
190 workspace: WeakViewHandle<Workspace>,
191 cx: AsyncAppContext,
192 ) -> Task<Result<ViewHandle<Self>>> {
193 cx.spawn(|mut cx| async move {
194 let serialized_panel = if let Some(panel) = cx
195 .background()
196 .spawn(async move { KEY_VALUE_STORE.read_kvp(CHAT_PANEL_KEY) })
197 .await
198 .log_err()
199 .flatten()
200 {
201 Some(serde_json::from_str::<SerializedChatPanel>(&panel)?)
202 } else {
203 None
204 };
205
206 workspace.update(&mut cx, |workspace, cx| {
207 let panel = Self::new(workspace, cx);
208 if let Some(serialized_panel) = serialized_panel {
209 panel.update(cx, |panel, cx| {
210 panel.width = serialized_panel.width;
211 cx.notify();
212 });
213 }
214 panel
215 })
216 })
217 }
218
219 fn serialize(&mut self, cx: &mut ViewContext<Self>) {
220 let width = self.width;
221 self.pending_serialization = cx.background().spawn(
222 async move {
223 KEY_VALUE_STORE
224 .write_kvp(
225 CHAT_PANEL_KEY.into(),
226 serde_json::to_string(&SerializedChatPanel { width })?,
227 )
228 .await?;
229 anyhow::Ok(())
230 }
231 .log_err(),
232 );
233 }
234
235 fn update_channel_count(&mut self, cx: &mut ViewContext<Self>) {
236 let channel_count = self.channel_store.read(cx).channel_count();
237 self.channel_select.update(cx, |select, cx| {
238 select.set_item_count(channel_count, cx);
239 });
240 }
241
242 fn set_active_chat(&mut self, chat: ModelHandle<ChannelChat>, cx: &mut ViewContext<Self>) {
243 if self.active_chat.as_ref().map(|e| &e.0) != Some(&chat) {
244 let id = chat.read(cx).channel().id;
245 {
246 let chat = chat.read(cx);
247 self.message_list.reset(chat.message_count());
248 let placeholder = format!("Message #{}", chat.channel().name);
249 self.input_editor.update(cx, move |editor, cx| {
250 editor.set_placeholder_text(placeholder, cx);
251 });
252 }
253 let subscription = cx.subscribe(&chat, Self::channel_did_change);
254 self.active_chat = Some((chat, subscription));
255 self.acknowledge_last_message(cx);
256 self.channel_select.update(cx, |select, cx| {
257 if let Some(ix) = self.channel_store.read(cx).index_of_channel(id) {
258 select.set_selected_index(ix, cx);
259 }
260 });
261 cx.notify();
262 }
263 }
264
265 fn channel_did_change(
266 &mut self,
267 _: ModelHandle<ChannelChat>,
268 event: &ChannelChatEvent,
269 cx: &mut ViewContext<Self>,
270 ) {
271 match event {
272 ChannelChatEvent::MessagesUpdated {
273 old_range,
274 new_count,
275 } => {
276 self.message_list.splice(old_range.clone(), *new_count);
277 if self.active {
278 self.acknowledge_last_message(cx);
279 }
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.new_message(*channel_id, *message_id, cx)
288 })
289 }
290 }
291 }
292 cx.notify();
293 }
294
295 fn acknowledge_last_message(&mut self, cx: &mut ViewContext<'_, '_, ChatPanel>) {
296 if self.active {
297 if let Some((chat, _)) = &self.active_chat {
298 chat.update(cx, |chat, cx| {
299 chat.acknowledge_last_message(cx);
300 });
301 }
302 }
303 }
304
305 fn render_channel(&self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
306 let theme = theme::current(cx);
307 Flex::column()
308 .with_child(
309 ChildView::new(&self.channel_select, cx)
310 .contained()
311 .with_style(theme.chat_panel.channel_select.container),
312 )
313 .with_child(self.render_active_channel_messages(&theme))
314 .with_child(self.render_input_box(&theme, cx))
315 .into_any()
316 }
317
318 fn render_active_channel_messages(&self, theme: &Arc<Theme>) -> AnyElement<Self> {
319 let messages = if self.active_chat.is_some() {
320 List::new(self.message_list.clone())
321 .contained()
322 .with_style(theme.chat_panel.list)
323 .into_any()
324 } else {
325 Empty::new().into_any()
326 };
327
328 messages.flex(1., true).into_any()
329 }
330
331 fn render_message(&self, ix: usize, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
332 let (message, is_continuation, is_last) = {
333 let active_chat = self.active_chat.as_ref().unwrap().0.read(cx);
334 let last_message = active_chat.message(ix.saturating_sub(1));
335 let this_message = active_chat.message(ix);
336 let is_continuation = last_message.id != this_message.id
337 && this_message.sender.id == last_message.sender.id;
338
339 (
340 active_chat.message(ix),
341 is_continuation,
342 active_chat.message_count() == ix + 1,
343 )
344 };
345
346 let now = OffsetDateTime::now_utc();
347 let theme = theme::current(cx);
348 let style = if message.is_pending() {
349 &theme.chat_panel.pending_message
350 } else if is_continuation {
351 &theme.chat_panel.continuation_message
352 } else {
353 &theme.chat_panel.message
354 };
355
356 let belongs_to_user = Some(message.sender.id) == self.client.user_id();
357 let message_id_to_remove =
358 if let (ChannelMessageId::Saved(id), true) = (message.id, belongs_to_user) {
359 Some(id)
360 } else {
361 None
362 };
363
364 enum DeleteMessage {}
365
366 let body = message.body.clone();
367 if is_continuation {
368 Flex::row()
369 .with_child(Text::new(body, style.body.clone()))
370 .with_children(message_id_to_remove.map(|id| {
371 MouseEventHandler::new::<DeleteMessage, _>(id as usize, cx, |mouse_state, _| {
372 let button_style = theme.chat_panel.icon_button.style_for(mouse_state);
373 render_icon_button(button_style, "icons/x.svg")
374 .aligned()
375 .into_any()
376 })
377 .with_padding(Padding::uniform(2.))
378 .with_cursor_style(CursorStyle::PointingHand)
379 .on_click(MouseButton::Left, move |_, this, cx| {
380 this.remove_message(id, cx);
381 })
382 .flex_float()
383 }))
384 .contained()
385 .with_style(style.container)
386 .with_margin_bottom(if is_last {
387 theme.chat_panel.last_message_bottom_spacing
388 } else {
389 0.
390 })
391 .into_any()
392 } else {
393 Flex::column()
394 .with_child(
395 Flex::row()
396 .with_child(
397 message
398 .sender
399 .avatar
400 .clone()
401 .map(|avatar| {
402 Image::from_data(avatar)
403 .with_style(theme.collab_panel.channel_avatar)
404 .into_any()
405 })
406 .unwrap_or_else(|| {
407 Empty::new()
408 .constrained()
409 .with_width(
410 theme.collab_panel.channel_avatar.width.unwrap_or(12.),
411 )
412 .into_any()
413 })
414 .contained()
415 .with_margin_right(4.),
416 )
417 .with_child(
418 Label::new(
419 message.sender.github_login.clone(),
420 style.sender.text.clone(),
421 )
422 .contained()
423 .with_style(style.sender.container),
424 )
425 .with_child(
426 Label::new(
427 format_timestamp(message.timestamp, now, self.local_timezone),
428 style.timestamp.text.clone(),
429 )
430 .contained()
431 .with_style(style.timestamp.container),
432 )
433 .with_children(message_id_to_remove.map(|id| {
434 MouseEventHandler::new::<DeleteMessage, _>(
435 id as usize,
436 cx,
437 |mouse_state, _| {
438 let button_style =
439 theme.chat_panel.icon_button.style_for(mouse_state);
440 render_icon_button(button_style, "icons/x.svg")
441 .aligned()
442 .into_any()
443 },
444 )
445 .with_padding(Padding::uniform(2.))
446 .with_cursor_style(CursorStyle::PointingHand)
447 .on_click(MouseButton::Left, move |_, this, cx| {
448 this.remove_message(id, cx);
449 })
450 .flex_float()
451 }))
452 .align_children_center(),
453 )
454 .with_child(Text::new(body, style.body.clone()))
455 .contained()
456 .with_style(style.container)
457 .with_margin_bottom(if is_last {
458 theme.chat_panel.last_message_bottom_spacing
459 } else {
460 0.
461 })
462 .into_any()
463 }
464 }
465
466 fn render_input_box(&self, theme: &Arc<Theme>, cx: &AppContext) -> AnyElement<Self> {
467 ChildView::new(&self.input_editor, cx)
468 .contained()
469 .with_style(theme.chat_panel.input_editor.container)
470 .into_any()
471 }
472
473 fn render_channel_name(
474 channel_store: &ModelHandle<ChannelStore>,
475 ix: usize,
476 item_type: ItemType,
477 is_hovered: bool,
478 workspace: WeakViewHandle<Workspace>,
479 cx: &mut ViewContext<Select>,
480 ) -> AnyElement<Select> {
481 let theme = theme::current(cx);
482 let tooltip_style = &theme.tooltip;
483 let theme = &theme.chat_panel;
484 let style = match (&item_type, is_hovered) {
485 (ItemType::Header, _) => &theme.channel_select.header,
486 (ItemType::Selected, _) => &theme.channel_select.active_item,
487 (ItemType::Unselected, false) => &theme.channel_select.item,
488 (ItemType::Unselected, true) => &theme.channel_select.hovered_item,
489 };
490
491 let channel = &channel_store.read(cx).channel_at(ix).unwrap();
492 let channel_id = channel.id;
493
494 let mut row = Flex::row()
495 .with_child(
496 Label::new("#".to_string(), style.hash.text.clone())
497 .contained()
498 .with_style(style.hash.container),
499 )
500 .with_child(Label::new(channel.name.clone(), style.name.clone()));
501
502 if matches!(item_type, ItemType::Header) {
503 row.add_children([
504 MouseEventHandler::new::<OpenChannelNotes, _>(0, cx, |mouse_state, _| {
505 render_icon_button(theme.icon_button.style_for(mouse_state), "icons/file.svg")
506 })
507 .on_click(MouseButton::Left, move |_, _, cx| {
508 if let Some(workspace) = workspace.upgrade(cx) {
509 ChannelView::open(channel_id, workspace, cx).detach();
510 }
511 })
512 .with_tooltip::<OpenChannelNotes>(
513 channel_id as usize,
514 "Open Notes",
515 Some(Box::new(OpenChannelNotes)),
516 tooltip_style.clone(),
517 cx,
518 )
519 .flex_float(),
520 MouseEventHandler::new::<ActiveCall, _>(0, cx, |mouse_state, _| {
521 render_icon_button(
522 theme.icon_button.style_for(mouse_state),
523 "icons/speaker-loud.svg",
524 )
525 })
526 .on_click(MouseButton::Left, move |_, _, cx| {
527 ActiveCall::global(cx)
528 .update(cx, |call, cx| call.join_channel(channel_id, cx))
529 .detach_and_log_err(cx);
530 })
531 .with_tooltip::<ActiveCall>(
532 channel_id as usize,
533 "Join Call",
534 Some(Box::new(JoinCall)),
535 tooltip_style.clone(),
536 cx,
537 )
538 .flex_float(),
539 ]);
540 }
541
542 row.align_children_center()
543 .contained()
544 .with_style(style.container)
545 .into_any()
546 }
547
548 fn render_sign_in_prompt(
549 &self,
550 theme: &Arc<Theme>,
551 cx: &mut ViewContext<Self>,
552 ) -> AnyElement<Self> {
553 enum SignInPromptLabel {}
554
555 MouseEventHandler::new::<SignInPromptLabel, _>(0, cx, |mouse_state, _| {
556 Label::new(
557 "Sign in to use chat".to_string(),
558 theme
559 .chat_panel
560 .sign_in_prompt
561 .style_for(mouse_state)
562 .clone(),
563 )
564 })
565 .with_cursor_style(CursorStyle::PointingHand)
566 .on_click(MouseButton::Left, move |_, this, cx| {
567 let client = this.client.clone();
568 cx.spawn(|this, mut cx| async move {
569 if client
570 .authenticate_and_connect(true, &cx)
571 .log_err()
572 .await
573 .is_some()
574 {
575 this.update(&mut cx, |this, cx| {
576 if cx.handle().is_focused(cx) {
577 cx.focus(&this.input_editor);
578 }
579 })
580 .ok();
581 }
582 })
583 .detach();
584 })
585 .aligned()
586 .into_any()
587 }
588
589 fn send(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
590 if let Some((chat, _)) = self.active_chat.as_ref() {
591 let body = self.input_editor.update(cx, |editor, cx| {
592 let body = editor.text(cx);
593 editor.clear(cx);
594 body
595 });
596
597 if let Some(task) = chat
598 .update(cx, |chat, cx| chat.send_message(body, cx))
599 .log_err()
600 {
601 task.detach();
602 }
603 }
604 }
605
606 fn remove_message(&mut self, id: u64, cx: &mut ViewContext<Self>) {
607 if let Some((chat, _)) = self.active_chat.as_ref() {
608 chat.update(cx, |chat, cx| chat.remove_message(id, cx).detach())
609 }
610 }
611
612 fn load_more_messages(&mut self, _: &LoadMoreMessages, cx: &mut ViewContext<Self>) {
613 if let Some((chat, _)) = self.active_chat.as_ref() {
614 chat.update(cx, |channel, cx| {
615 channel.load_more_messages(cx);
616 })
617 }
618 }
619
620 pub fn select_channel(
621 &mut self,
622 selected_channel_id: u64,
623 cx: &mut ViewContext<ChatPanel>,
624 ) -> Task<Result<()>> {
625 if let Some((chat, _)) = &self.active_chat {
626 if chat.read(cx).channel().id == selected_channel_id {
627 return Task::ready(Ok(()));
628 }
629 }
630
631 let open_chat = self.channel_store.update(cx, |store, cx| {
632 store.open_channel_chat(selected_channel_id, cx)
633 });
634 cx.spawn(|this, mut cx| async move {
635 let chat = open_chat.await?;
636 this.update(&mut cx, |this, cx| {
637 this.set_active_chat(chat, cx);
638 })
639 })
640 }
641
642 fn open_notes(&mut self, _: &OpenChannelNotes, cx: &mut ViewContext<Self>) {
643 if let Some((chat, _)) = &self.active_chat {
644 let channel_id = chat.read(cx).channel().id;
645 if let Some(workspace) = self.workspace.upgrade(cx) {
646 ChannelView::open(channel_id, workspace, cx).detach();
647 }
648 }
649 }
650
651 fn join_call(&mut self, _: &JoinCall, cx: &mut ViewContext<Self>) {
652 if let Some((chat, _)) = &self.active_chat {
653 let channel_id = chat.read(cx).channel().id;
654 ActiveCall::global(cx)
655 .update(cx, |call, cx| call.join_channel(channel_id, cx))
656 .detach_and_log_err(cx);
657 }
658 }
659}
660
661impl Entity for ChatPanel {
662 type Event = Event;
663}
664
665impl View for ChatPanel {
666 fn ui_name() -> &'static str {
667 "ChatPanel"
668 }
669
670 fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
671 let theme = theme::current(cx);
672 let element = if self.client.user_id().is_some() {
673 self.render_channel(cx)
674 } else {
675 self.render_sign_in_prompt(&theme, cx)
676 };
677 element
678 .contained()
679 .with_style(theme.chat_panel.container)
680 .constrained()
681 .with_min_width(150.)
682 .into_any()
683 }
684
685 fn focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {
686 self.has_focus = true;
687 if matches!(
688 *self.client.status().borrow(),
689 client::Status::Connected { .. }
690 ) {
691 cx.focus(&self.input_editor);
692 }
693 }
694
695 fn focus_out(&mut self, _: AnyViewHandle, _: &mut ViewContext<Self>) {
696 self.has_focus = false;
697 }
698}
699
700impl Panel for ChatPanel {
701 fn position(&self, cx: &gpui::WindowContext) -> DockPosition {
702 settings::get::<ChatPanelSettings>(cx).dock
703 }
704
705 fn position_is_valid(&self, position: DockPosition) -> bool {
706 matches!(position, DockPosition::Left | DockPosition::Right)
707 }
708
709 fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
710 settings::update_settings_file::<ChatPanelSettings>(self.fs.clone(), cx, move |settings| {
711 settings.dock = Some(position)
712 });
713 }
714
715 fn size(&self, cx: &gpui::WindowContext) -> f32 {
716 self.width
717 .unwrap_or_else(|| settings::get::<ChatPanelSettings>(cx).default_width)
718 }
719
720 fn set_size(&mut self, size: Option<f32>, cx: &mut ViewContext<Self>) {
721 self.width = size;
722 self.serialize(cx);
723 cx.notify();
724 }
725
726 fn set_active(&mut self, active: bool, cx: &mut ViewContext<Self>) {
727 self.active = active;
728 if active {
729 self.acknowledge_last_message(cx);
730 if !is_chat_feature_enabled(cx) {
731 cx.emit(Event::Dismissed);
732 }
733 }
734 }
735
736 fn icon_path(&self, cx: &gpui::WindowContext) -> Option<&'static str> {
737 (settings::get::<ChatPanelSettings>(cx).button && is_chat_feature_enabled(cx))
738 .then(|| "icons/conversations.svg")
739 }
740
741 fn icon_tooltip(&self) -> (String, Option<Box<dyn gpui::Action>>) {
742 ("Chat Panel".to_string(), Some(Box::new(ToggleFocus)))
743 }
744
745 fn should_change_position_on_event(event: &Self::Event) -> bool {
746 matches!(event, Event::DockPositionChanged)
747 }
748
749 fn should_close_on_event(event: &Self::Event) -> bool {
750 matches!(event, Event::Dismissed)
751 }
752
753 fn has_focus(&self, _cx: &gpui::WindowContext) -> bool {
754 self.has_focus
755 }
756
757 fn is_focus_event(event: &Self::Event) -> bool {
758 matches!(event, Event::Focus)
759 }
760}
761
762fn is_chat_feature_enabled(cx: &gpui::WindowContext<'_>) -> bool {
763 cx.is_staff() || cx.has_flag::<ChannelsAlpha>()
764}
765
766fn format_timestamp(
767 mut timestamp: OffsetDateTime,
768 mut now: OffsetDateTime,
769 local_timezone: UtcOffset,
770) -> String {
771 timestamp = timestamp.to_offset(local_timezone);
772 now = now.to_offset(local_timezone);
773
774 let today = now.date();
775 let date = timestamp.date();
776 let mut hour = timestamp.hour();
777 let mut part = "am";
778 if hour > 12 {
779 hour -= 12;
780 part = "pm";
781 }
782 if date == today {
783 format!("{:02}:{:02}{}", hour, timestamp.minute(), part)
784 } else if date.next_day() == Some(today) {
785 format!("yesterday at {:02}:{:02}{}", hour, timestamp.minute(), part)
786 } else {
787 format!("{:02}/{}/{}", date.month() as u32, date.day(), date.year())
788 }
789}
790
791fn render_icon_button<V: View>(style: &IconButton, svg_path: &'static str) -> impl Element<V> {
792 Svg::new(svg_path)
793 .with_color(style.color)
794 .constrained()
795 .with_width(style.icon_width)
796 .aligned()
797 .constrained()
798 .with_width(style.button_width)
799 .with_height(style.button_width)
800 .contained()
801 .with_style(style.container)
802}