notification_panel.rs

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