notification_panel.rs

  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}