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