notification_panel.rs

  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(&notification, 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}