1use crate::{
2 chat_panel::ChatPanel, format_timestamp, is_channels_feature_enabled, render_avatar,
3 NotificationPanelSettings,
4};
5use anyhow::Result;
6use channel::ChannelStore;
7use client::{Client, Notification, User, UserStore};
8use db::kvp::KEY_VALUE_STORE;
9use futures::StreamExt;
10use gpui::{
11 actions,
12 elements::*,
13 platform::{CursorStyle, MouseButton},
14 serde_json, AnyViewHandle, AppContext, AsyncAppContext, Entity, ModelHandle, Task, View,
15 ViewContext, ViewHandle, WeakViewHandle, WindowContext,
16};
17use notifications::{NotificationEntry, NotificationEvent, NotificationStore};
18use project::Fs;
19use serde::{Deserialize, Serialize};
20use settings::SettingsStore;
21use std::{sync::Arc, time::Duration};
22use theme::{IconButton, Theme};
23use time::{OffsetDateTime, UtcOffset};
24use util::{ResultExt, TryFutureExt};
25use workspace::{
26 dock::{DockPosition, Panel},
27 Workspace,
28};
29
30const TOAST_DURATION: Duration = Duration::from_secs(5);
31const NOTIFICATION_PANEL_KEY: &'static str = "NotificationPanel";
32
33pub struct NotificationPanel {
34 client: Arc<Client>,
35 user_store: ModelHandle<UserStore>,
36 channel_store: ModelHandle<ChannelStore>,
37 notification_store: ModelHandle<NotificationStore>,
38 fs: Arc<dyn Fs>,
39 width: Option<f32>,
40 active: bool,
41 notification_list: ListState<Self>,
42 pending_serialization: Task<Option<()>>,
43 subscriptions: Vec<gpui::Subscription>,
44 workspace: WeakViewHandle<Workspace>,
45 current_notification_toast: Option<(u64, Task<()>)>,
46 local_timezone: UtcOffset,
47 has_focus: bool,
48}
49
50#[derive(Serialize, Deserialize)]
51struct SerializedNotificationPanel {
52 width: Option<f32>,
53}
54
55#[derive(Debug)]
56pub enum Event {
57 DockPositionChanged,
58 Focus,
59 Dismissed,
60}
61
62pub struct NotificationPresenter {
63 pub actor: Option<Arc<client::User>>,
64 pub text: String,
65 pub icon: &'static str,
66 pub needs_response: bool,
67 pub can_navigate: bool,
68}
69
70actions!(notification_panel, [ToggleFocus]);
71
72pub fn init(_cx: &mut AppContext) {}
73
74impl NotificationPanel {
75 pub fn new(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) -> ViewHandle<Self> {
76 let fs = workspace.app_state().fs.clone();
77 let client = workspace.app_state().client.clone();
78 let user_store = workspace.app_state().user_store.clone();
79 let workspace_handle = workspace.weak_handle();
80
81 cx.add_view(|cx| {
82 let mut status = client.status();
83 cx.spawn(|this, mut cx| async move {
84 while let Some(_) = status.next().await {
85 if this
86 .update(&mut cx, |_, cx| {
87 cx.notify();
88 })
89 .is_err()
90 {
91 break;
92 }
93 }
94 })
95 .detach();
96
97 let notification_list =
98 ListState::<Self>::new(0, Orientation::Top, 1000., move |this, ix, cx| {
99 this.render_notification(ix, cx)
100 .unwrap_or_else(|| Empty::new().into_any())
101 });
102
103 let mut this = Self {
104 fs,
105 client,
106 user_store,
107 local_timezone: cx.platform().local_timezone(),
108 channel_store: ChannelStore::global(cx),
109 notification_store: NotificationStore::global(cx),
110 notification_list,
111 pending_serialization: Task::ready(None),
112 workspace: workspace_handle,
113 has_focus: false,
114 current_notification_toast: None,
115 subscriptions: Vec::new(),
116 active: false,
117 width: None,
118 };
119
120 let mut old_dock_position = this.position(cx);
121 this.subscriptions.extend([
122 cx.subscribe(&this.notification_store, Self::on_notification_event),
123 cx.observe_global::<SettingsStore, _>(move |this: &mut Self, cx| {
124 let new_dock_position = this.position(cx);
125 if new_dock_position != old_dock_position {
126 old_dock_position = new_dock_position;
127 cx.emit(Event::DockPositionChanged);
128 }
129 cx.notify();
130 }),
131 ]);
132 this
133 })
134 }
135
136 pub fn load(
137 workspace: WeakViewHandle<Workspace>,
138 cx: AsyncAppContext,
139 ) -> Task<Result<ViewHandle<Self>>> {
140 cx.spawn(|mut cx| async move {
141 let serialized_panel = if let Some(panel) = cx
142 .background()
143 .spawn(async move { KEY_VALUE_STORE.read_kvp(NOTIFICATION_PANEL_KEY) })
144 .await
145 .log_err()
146 .flatten()
147 {
148 Some(serde_json::from_str::<SerializedNotificationPanel>(&panel)?)
149 } else {
150 None
151 };
152
153 workspace.update(&mut cx, |workspace, cx| {
154 let panel = Self::new(workspace, cx);
155 if let Some(serialized_panel) = serialized_panel {
156 panel.update(cx, |panel, cx| {
157 panel.width = serialized_panel.width;
158 cx.notify();
159 });
160 }
161 panel
162 })
163 })
164 }
165
166 fn serialize(&mut self, cx: &mut ViewContext<Self>) {
167 let width = self.width;
168 self.pending_serialization = cx.background().spawn(
169 async move {
170 KEY_VALUE_STORE
171 .write_kvp(
172 NOTIFICATION_PANEL_KEY.into(),
173 serde_json::to_string(&SerializedNotificationPanel { width })?,
174 )
175 .await?;
176 anyhow::Ok(())
177 }
178 .log_err(),
179 );
180 }
181
182 fn render_notification(
183 &mut self,
184 ix: usize,
185 cx: &mut ViewContext<Self>,
186 ) -> Option<AnyElement<Self>> {
187 let entry = self.notification_store.read(cx).notification_at(ix)?;
188 let now = OffsetDateTime::now_utc();
189 let timestamp = entry.timestamp;
190 let NotificationPresenter {
191 actor,
192 text,
193 icon,
194 needs_response,
195 can_navigate,
196 } = self.present_notification(entry, cx)?;
197
198 let theme = theme::current(cx);
199 let style = &theme.notification_panel;
200 let response = entry.response;
201 let notification = entry.notification.clone();
202
203 let message_style = if entry.is_read {
204 style.read_text.clone()
205 } else {
206 style.unread_text.clone()
207 };
208
209 enum Decline {}
210 enum Accept {}
211
212 Some(
213 MouseEventHandler::new::<NotificationEntry, _>(ix, cx, |_, cx| {
214 let container = message_style.container;
215
216 Flex::column()
217 .with_child(
218 Flex::row()
219 .with_children(
220 actor.map(|actor| render_avatar(actor.avatar.clone(), &theme)),
221 )
222 .with_child(render_icon_button(&theme.chat_panel.icon_button, icon))
223 .with_child(
224 Label::new(
225 format_timestamp(timestamp, now, self.local_timezone),
226 style.timestamp.text.clone(),
227 )
228 .contained()
229 .with_style(style.timestamp.container),
230 )
231 .align_children_center(),
232 )
233 .with_child(Text::new(text, message_style.text.clone()))
234 .with_children(if let Some(is_accepted) = response {
235 Some(
236 Label::new(
237 if is_accepted { "Accepted" } else { "Declined" },
238 style.button.text.clone(),
239 )
240 .into_any(),
241 )
242 } else if needs_response {
243 Some(
244 Flex::row()
245 .with_children([
246 MouseEventHandler::new::<Decline, _>(ix, cx, |state, _| {
247 let button = style.button.style_for(state);
248 Label::new("Decline", button.text.clone())
249 .contained()
250 .with_style(button.container)
251 })
252 .with_cursor_style(CursorStyle::PointingHand)
253 .on_click(
254 MouseButton::Left,
255 {
256 let notification = notification.clone();
257 move |_, view, cx| {
258 view.respond_to_notification(
259 notification.clone(),
260 false,
261 cx,
262 );
263 }
264 },
265 ),
266 MouseEventHandler::new::<Accept, _>(ix, cx, |state, _| {
267 let button = style.button.style_for(state);
268 Label::new("Accept", button.text.clone())
269 .contained()
270 .with_style(button.container)
271 })
272 .with_cursor_style(CursorStyle::PointingHand)
273 .on_click(
274 MouseButton::Left,
275 {
276 let notification = notification.clone();
277 move |_, view, cx| {
278 view.respond_to_notification(
279 notification.clone(),
280 true,
281 cx,
282 );
283 }
284 },
285 ),
286 ])
287 .aligned()
288 .right()
289 .into_any(),
290 )
291 } else {
292 None
293 })
294 .contained()
295 .with_style(container)
296 .into_any()
297 })
298 .with_cursor_style(if can_navigate {
299 CursorStyle::PointingHand
300 } else {
301 CursorStyle::default()
302 })
303 .on_click(MouseButton::Left, {
304 let notification = notification.clone();
305 move |_, this, cx| this.did_click_notification(¬ification, cx)
306 })
307 .into_any(),
308 )
309 }
310
311 fn present_notification(
312 &self,
313 entry: &NotificationEntry,
314 cx: &AppContext,
315 ) -> Option<NotificationPresenter> {
316 let user_store = self.user_store.read(cx);
317 let channel_store = self.channel_store.read(cx);
318 match entry.notification {
319 Notification::ContactRequest { sender_id } => {
320 let requester = user_store.get_cached_user(sender_id)?;
321 Some(NotificationPresenter {
322 icon: "icons/plus.svg",
323 text: format!("{} wants to add you as a contact", requester.github_login),
324 needs_response: user_store.is_contact_request_pending(&requester),
325 actor: Some(requester),
326 can_navigate: false,
327 })
328 }
329 Notification::ContactRequestAccepted { responder_id } => {
330 let responder = user_store.get_cached_user(responder_id)?;
331 Some(NotificationPresenter {
332 icon: "icons/plus.svg",
333 text: format!("{} accepted your contact invite", responder.github_login),
334 needs_response: false,
335 actor: Some(responder),
336 can_navigate: false,
337 })
338 }
339 Notification::ChannelInvitation {
340 ref channel_name,
341 channel_id,
342 inviter_id,
343 } => {
344 let inviter = user_store.get_cached_user(inviter_id)?;
345 Some(NotificationPresenter {
346 icon: "icons/hash.svg",
347 text: format!(
348 "{} invited you to join the #{channel_name} channel",
349 inviter.github_login
350 ),
351 needs_response: channel_store.has_channel_invitation(channel_id),
352 actor: Some(inviter),
353 can_navigate: false,
354 })
355 }
356 Notification::ChannelMessageMention {
357 sender_id,
358 channel_id,
359 message_id,
360 } => {
361 let sender = user_store.get_cached_user(sender_id)?;
362 let channel = channel_store.channel_for_id(channel_id)?;
363 let message = self
364 .notification_store
365 .read(cx)
366 .channel_message_for_id(message_id)?;
367 Some(NotificationPresenter {
368 icon: "icons/conversations.svg",
369 text: format!(
370 "{} mentioned you in the #{} channel:\n{}",
371 sender.github_login, channel.name, message.body,
372 ),
373 needs_response: false,
374 actor: Some(sender),
375 can_navigate: true,
376 })
377 }
378 }
379 }
380
381 fn did_click_notification(&mut self, notification: &Notification, cx: &mut ViewContext<Self>) {
382 if let Notification::ChannelMessageMention {
383 message_id,
384 channel_id,
385 ..
386 } = notification.clone()
387 {
388 if let Some(workspace) = self.workspace.upgrade(cx) {
389 cx.app_context().defer(move |cx| {
390 workspace.update(cx, |workspace, cx| {
391 if let Some(panel) = workspace.focus_panel::<ChatPanel>(cx) {
392 panel.update(cx, |panel, cx| {
393 panel
394 .select_channel(channel_id, Some(message_id), cx)
395 .detach_and_log_err(cx);
396 });
397 }
398 });
399 });
400 }
401 }
402 }
403
404 fn render_sign_in_prompt(
405 &self,
406 theme: &Arc<Theme>,
407 cx: &mut ViewContext<Self>,
408 ) -> AnyElement<Self> {
409 enum SignInPromptLabel {}
410
411 MouseEventHandler::new::<SignInPromptLabel, _>(0, cx, |mouse_state, _| {
412 Label::new(
413 "Sign in to view your notifications".to_string(),
414 theme
415 .chat_panel
416 .sign_in_prompt
417 .style_for(mouse_state)
418 .clone(),
419 )
420 })
421 .with_cursor_style(CursorStyle::PointingHand)
422 .on_click(MouseButton::Left, move |_, this, cx| {
423 let client = this.client.clone();
424 cx.spawn(|_, cx| async move {
425 client.authenticate_and_connect(true, &cx).log_err().await;
426 })
427 .detach();
428 })
429 .aligned()
430 .into_any()
431 }
432
433 fn render_empty_state(
434 &self,
435 theme: &Arc<Theme>,
436 _cx: &mut ViewContext<Self>,
437 ) -> AnyElement<Self> {
438 Label::new(
439 "You have no notifications".to_string(),
440 theme.chat_panel.sign_in_prompt.default.clone(),
441 )
442 .aligned()
443 .into_any()
444 }
445
446 fn on_notification_event(
447 &mut self,
448 _: ModelHandle<NotificationStore>,
449 event: &NotificationEvent,
450 cx: &mut ViewContext<Self>,
451 ) {
452 match event {
453 NotificationEvent::NewNotification { entry } => self.add_toast(entry, cx),
454 NotificationEvent::NotificationRemoved { entry }
455 | NotificationEvent::NotificationRead { entry } => self.remove_toast(entry.id, cx),
456 NotificationEvent::NotificationsUpdated {
457 old_range,
458 new_count,
459 } => {
460 self.notification_list.splice(old_range.clone(), *new_count);
461 cx.notify();
462 }
463 }
464 }
465
466 fn add_toast(&mut self, entry: &NotificationEntry, cx: &mut ViewContext<Self>) {
467 let Some(NotificationPresenter { actor, text, .. }) = self.present_notification(entry, cx)
468 else {
469 return;
470 };
471
472 let id = entry.id;
473 self.current_notification_toast = Some((
474 id,
475 cx.spawn(|this, mut cx| async move {
476 cx.background().timer(TOAST_DURATION).await;
477 this.update(&mut cx, |this, cx| this.remove_toast(id, cx))
478 .ok();
479 }),
480 ));
481
482 self.workspace
483 .update(cx, |workspace, cx| {
484 workspace.show_notification(0, cx, |cx| {
485 let workspace = cx.weak_handle();
486 cx.add_view(|_| NotificationToast {
487 actor,
488 text,
489 workspace,
490 })
491 })
492 })
493 .ok();
494 }
495
496 fn remove_toast(&mut self, notification_id: u64, cx: &mut ViewContext<Self>) {
497 if let Some((current_id, _)) = &self.current_notification_toast {
498 if *current_id == notification_id {
499 self.current_notification_toast.take();
500 self.workspace
501 .update(cx, |workspace, cx| {
502 workspace.dismiss_notification::<NotificationToast>(0, cx)
503 })
504 .ok();
505 }
506 }
507 }
508
509 fn respond_to_notification(
510 &mut self,
511 notification: Notification,
512 response: bool,
513 cx: &mut ViewContext<Self>,
514 ) {
515 self.notification_store.update(cx, |store, cx| {
516 store.respond_to_notification(notification, response, cx);
517 });
518 }
519}
520
521impl Entity for NotificationPanel {
522 type Event = Event;
523}
524
525impl View for NotificationPanel {
526 fn ui_name() -> &'static str {
527 "NotificationPanel"
528 }
529
530 fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
531 let theme = theme::current(cx);
532 let element = if self.client.user_id().is_none() {
533 self.render_sign_in_prompt(&theme, cx)
534 } else if self.notification_list.item_count() == 0 {
535 self.render_empty_state(&theme, cx)
536 } else {
537 List::new(self.notification_list.clone())
538 .contained()
539 .with_style(theme.chat_panel.list)
540 .into_any()
541 };
542 element
543 .contained()
544 .with_style(theme.chat_panel.container)
545 .constrained()
546 .with_min_width(150.)
547 .into_any()
548 }
549
550 fn focus_in(&mut self, _: AnyViewHandle, _: &mut ViewContext<Self>) {
551 self.has_focus = true;
552 }
553
554 fn focus_out(&mut self, _: AnyViewHandle, _: &mut ViewContext<Self>) {
555 self.has_focus = false;
556 }
557}
558
559impl Panel for NotificationPanel {
560 fn position(&self, cx: &gpui::WindowContext) -> DockPosition {
561 settings::get::<NotificationPanelSettings>(cx).dock
562 }
563
564 fn position_is_valid(&self, position: DockPosition) -> bool {
565 matches!(position, DockPosition::Left | DockPosition::Right)
566 }
567
568 fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
569 settings::update_settings_file::<NotificationPanelSettings>(
570 self.fs.clone(),
571 cx,
572 move |settings| settings.dock = Some(position),
573 );
574 }
575
576 fn size(&self, cx: &gpui::WindowContext) -> f32 {
577 self.width
578 .unwrap_or_else(|| settings::get::<NotificationPanelSettings>(cx).default_width)
579 }
580
581 fn set_size(&mut self, size: Option<f32>, cx: &mut ViewContext<Self>) {
582 self.width = size;
583 self.serialize(cx);
584 cx.notify();
585 }
586
587 fn set_active(&mut self, active: bool, cx: &mut ViewContext<Self>) {
588 self.active = active;
589 if active {
590 if !is_channels_feature_enabled(cx) {
591 cx.emit(Event::Dismissed);
592 }
593 }
594 }
595
596 fn icon_path(&self, cx: &gpui::WindowContext) -> Option<&'static str> {
597 (settings::get::<NotificationPanelSettings>(cx).button && is_channels_feature_enabled(cx))
598 .then(|| "icons/bell.svg")
599 }
600
601 fn icon_tooltip(&self) -> (String, Option<Box<dyn gpui::Action>>) {
602 (
603 "Notification Panel".to_string(),
604 Some(Box::new(ToggleFocus)),
605 )
606 }
607
608 fn icon_label(&self, cx: &WindowContext) -> Option<String> {
609 let count = self.notification_store.read(cx).unread_notification_count();
610 if count == 0 {
611 None
612 } else {
613 Some(count.to_string())
614 }
615 }
616
617 fn should_change_position_on_event(event: &Self::Event) -> bool {
618 matches!(event, Event::DockPositionChanged)
619 }
620
621 fn should_close_on_event(event: &Self::Event) -> bool {
622 matches!(event, Event::Dismissed)
623 }
624
625 fn has_focus(&self, _cx: &gpui::WindowContext) -> bool {
626 self.has_focus
627 }
628
629 fn is_focus_event(event: &Self::Event) -> bool {
630 matches!(event, Event::Focus)
631 }
632}
633
634fn render_icon_button<V: View>(style: &IconButton, svg_path: &'static str) -> impl Element<V> {
635 Svg::new(svg_path)
636 .with_color(style.color)
637 .constrained()
638 .with_width(style.icon_width)
639 .aligned()
640 .constrained()
641 .with_width(style.button_width)
642 .with_height(style.button_width)
643 .contained()
644 .with_style(style.container)
645}
646
647pub struct NotificationToast {
648 actor: Option<Arc<User>>,
649 text: String,
650 workspace: WeakViewHandle<Workspace>,
651}
652
653pub enum ToastEvent {
654 Dismiss,
655}
656
657impl NotificationToast {
658 fn focus_notification_panel(&self, cx: &mut AppContext) {
659 let workspace = self.workspace.clone();
660 cx.defer(move |cx| {
661 workspace
662 .update(cx, |workspace, cx| {
663 workspace.focus_panel::<NotificationPanel>(cx);
664 })
665 .ok();
666 })
667 }
668}
669
670impl Entity for NotificationToast {
671 type Event = ToastEvent;
672}
673
674impl View for NotificationToast {
675 fn ui_name() -> &'static str {
676 "ContactNotification"
677 }
678
679 fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
680 let user = self.actor.clone();
681 let theme = theme::current(cx).clone();
682 let theme = &theme.contact_notification;
683
684 MouseEventHandler::new::<Self, _>(0, cx, |_, cx| {
685 Flex::row()
686 .with_children(user.and_then(|user| {
687 Some(
688 Image::from_data(user.avatar.clone()?)
689 .with_style(theme.header_avatar)
690 .aligned()
691 .constrained()
692 .with_height(
693 cx.font_cache()
694 .line_height(theme.header_message.text.font_size),
695 )
696 .aligned()
697 .top(),
698 )
699 }))
700 .with_child(
701 Text::new(self.text.clone(), theme.header_message.text.clone())
702 .contained()
703 .with_style(theme.header_message.container)
704 .aligned()
705 .top()
706 .left()
707 .flex(1., true),
708 )
709 .with_child(
710 MouseEventHandler::new::<ToastEvent, _>(0, cx, |state, _| {
711 let style = theme.dismiss_button.style_for(state);
712 Svg::new("icons/x.svg")
713 .with_color(style.color)
714 .constrained()
715 .with_width(style.icon_width)
716 .aligned()
717 .contained()
718 .with_style(style.container)
719 .constrained()
720 .with_width(style.button_width)
721 .with_height(style.button_width)
722 })
723 .with_cursor_style(CursorStyle::PointingHand)
724 .with_padding(Padding::uniform(5.))
725 .on_click(MouseButton::Left, move |_, _, cx| {
726 cx.emit(ToastEvent::Dismiss)
727 })
728 .aligned()
729 .constrained()
730 .with_height(
731 cx.font_cache()
732 .line_height(theme.header_message.text.font_size),
733 )
734 .aligned()
735 .top()
736 .flex_float(),
737 )
738 .contained()
739 })
740 .with_cursor_style(CursorStyle::PointingHand)
741 .on_click(MouseButton::Left, move |_, this, cx| {
742 this.focus_notification_panel(cx);
743 cx.emit(ToastEvent::Dismiss);
744 })
745 .into_any()
746 }
747}
748
749impl workspace::notifications::Notification for NotificationToast {
750 fn should_dismiss_notification_on_event(&self, event: &<Self as Entity>::Event) -> bool {
751 matches!(event, ToastEvent::Dismiss)
752 }
753}