notification_panel.rs

  1use crate::{
  2    format_timestamp, is_channels_feature_enabled,
  3    notifications::contact_notification::ContactNotification, render_avatar,
  4    NotificationPanelSettings,
  5};
  6use anyhow::Result;
  7use channel::ChannelStore;
  8use client::{Client, Notification, UserStore};
  9use db::kvp::KEY_VALUE_STORE;
 10use futures::StreamExt;
 11use gpui::{
 12    actions,
 13    elements::*,
 14    platform::{CursorStyle, MouseButton},
 15    serde_json, AnyViewHandle, AppContext, AsyncAppContext, Entity, ModelHandle, Task, View,
 16    ViewContext, ViewHandle, WeakViewHandle, WindowContext,
 17};
 18use notifications::{NotificationEntry, NotificationEvent, NotificationStore};
 19use project::Fs;
 20use serde::{Deserialize, Serialize};
 21use settings::SettingsStore;
 22use std::sync::Arc;
 23use theme::{IconButton, Theme};
 24use time::{OffsetDateTime, UtcOffset};
 25use util::{ResultExt, TryFutureExt};
 26use workspace::{
 27    dock::{DockPosition, Panel},
 28    Workspace,
 29};
 30
 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    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!(chat_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        let notification_list =
 73            ListState::<Self>::new(0, Orientation::Top, 1000., move |this, ix, cx| {
 74                this.render_notification(ix, cx)
 75            });
 76
 77        cx.add_view(|cx| {
 78            let mut status = client.status();
 79
 80            cx.spawn(|this, mut cx| async move {
 81                while let Some(_) = status.next().await {
 82                    if this
 83                        .update(&mut cx, |_, cx| {
 84                            cx.notify();
 85                        })
 86                        .is_err()
 87                    {
 88                        break;
 89                    }
 90                }
 91            })
 92            .detach();
 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                subscriptions: Vec::new(),
106                active: false,
107                width: None,
108            };
109
110            let mut old_dock_position = this.position(cx);
111            this.subscriptions.extend([
112                cx.subscribe(&this.notification_store, Self::on_notification_event),
113                cx.observe_global::<SettingsStore, _>(move |this: &mut Self, cx| {
114                    let new_dock_position = this.position(cx);
115                    if new_dock_position != old_dock_position {
116                        old_dock_position = new_dock_position;
117                        cx.emit(Event::DockPositionChanged);
118                    }
119                    cx.notify();
120                }),
121            ]);
122            this
123        })
124    }
125
126    pub fn load(
127        workspace: WeakViewHandle<Workspace>,
128        cx: AsyncAppContext,
129    ) -> Task<Result<ViewHandle<Self>>> {
130        cx.spawn(|mut cx| async move {
131            let serialized_panel = if let Some(panel) = cx
132                .background()
133                .spawn(async move { KEY_VALUE_STORE.read_kvp(NOTIFICATION_PANEL_KEY) })
134                .await
135                .log_err()
136                .flatten()
137            {
138                Some(serde_json::from_str::<SerializedNotificationPanel>(&panel)?)
139            } else {
140                None
141            };
142
143            workspace.update(&mut cx, |workspace, cx| {
144                let panel = Self::new(workspace, cx);
145                if let Some(serialized_panel) = serialized_panel {
146                    panel.update(cx, |panel, cx| {
147                        panel.width = serialized_panel.width;
148                        cx.notify();
149                    });
150                }
151                panel
152            })
153        })
154    }
155
156    fn serialize(&mut self, cx: &mut ViewContext<Self>) {
157        let width = self.width;
158        self.pending_serialization = cx.background().spawn(
159            async move {
160                KEY_VALUE_STORE
161                    .write_kvp(
162                        NOTIFICATION_PANEL_KEY.into(),
163                        serde_json::to_string(&SerializedNotificationPanel { width })?,
164                    )
165                    .await?;
166                anyhow::Ok(())
167            }
168            .log_err(),
169        );
170    }
171
172    fn render_notification(&mut self, ix: usize, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
173        self.try_render_notification(ix, cx)
174            .unwrap_or_else(|| Empty::new().into_any())
175    }
176
177    fn try_render_notification(
178        &mut self,
179        ix: usize,
180        cx: &mut ViewContext<Self>,
181    ) -> Option<AnyElement<Self>> {
182        let notification_store = self.notification_store.read(cx);
183        let user_store = self.user_store.read(cx);
184        let channel_store = self.channel_store.read(cx);
185        let entry = notification_store.notification_at(ix)?;
186        let notification = entry.notification.clone();
187        let now = OffsetDateTime::now_utc();
188        let timestamp = entry.timestamp;
189
190        let icon;
191        let text;
192        let actor;
193        let needs_acceptance;
194        match notification {
195            Notification::ContactRequest { sender_id } => {
196                let requester = user_store.get_cached_user(sender_id)?;
197                icon = "icons/plus.svg";
198                text = format!("{} wants to add you as a contact", requester.github_login);
199                needs_acceptance = true;
200                actor = Some(requester);
201            }
202            Notification::ContactRequestAccepted { responder_id } => {
203                let responder = user_store.get_cached_user(responder_id)?;
204                icon = "icons/plus.svg";
205                text = format!("{} accepted your contact invite", responder.github_login);
206                needs_acceptance = false;
207                actor = Some(responder);
208            }
209            Notification::ChannelInvitation {
210                ref channel_name, ..
211            } => {
212                actor = None;
213                icon = "icons/hash.svg";
214                text = format!("you were invited to join the #{channel_name} channel");
215                needs_acceptance = true;
216            }
217            Notification::ChannelMessageMention {
218                sender_id,
219                channel_id,
220                message_id,
221            } => {
222                let sender = user_store.get_cached_user(sender_id)?;
223                let channel = channel_store.channel_for_id(channel_id)?;
224                let message = notification_store.channel_message_for_id(message_id)?;
225
226                icon = "icons/conversations.svg";
227                text = format!(
228                    "{} mentioned you in the #{} channel:\n{}",
229                    sender.github_login, channel.name, message.body,
230                );
231                needs_acceptance = false;
232                actor = Some(sender);
233            }
234        }
235
236        let theme = theme::current(cx);
237        let style = &theme.notification_panel;
238        let response = entry.response;
239
240        let message_style = if entry.is_read {
241            style.read_text.clone()
242        } else {
243            style.unread_text.clone()
244        };
245
246        enum Decline {}
247        enum Accept {}
248
249        Some(
250            MouseEventHandler::new::<NotificationEntry, _>(ix, cx, |_, cx| {
251                let container = message_style.container;
252
253                Flex::column()
254                    .with_child(
255                        Flex::row()
256                            .with_children(
257                                actor.map(|actor| render_avatar(actor.avatar.clone(), &theme)),
258                            )
259                            .with_child(render_icon_button(&theme.chat_panel.icon_button, icon))
260                            .with_child(
261                                Label::new(
262                                    format_timestamp(timestamp, now, self.local_timezone),
263                                    style.timestamp.text.clone(),
264                                )
265                                .contained()
266                                .with_style(style.timestamp.container),
267                            )
268                            .align_children_center(),
269                    )
270                    .with_child(Text::new(text, message_style.text.clone()))
271                    .with_children(if let Some(is_accepted) = response {
272                        Some(
273                            Label::new(
274                                if is_accepted { "Accepted" } else { "Declined" },
275                                style.button.text.clone(),
276                            )
277                            .into_any(),
278                        )
279                    } else if needs_acceptance {
280                        Some(
281                            Flex::row()
282                                .with_children([
283                                    MouseEventHandler::new::<Decline, _>(ix, cx, |state, _| {
284                                        let button = style.button.style_for(state);
285                                        Label::new("Decline", button.text.clone())
286                                            .contained()
287                                            .with_style(button.container)
288                                    })
289                                    .with_cursor_style(CursorStyle::PointingHand)
290                                    .on_click(
291                                        MouseButton::Left,
292                                        {
293                                            let notification = notification.clone();
294                                            move |_, view, cx| {
295                                                view.respond_to_notification(
296                                                    notification.clone(),
297                                                    false,
298                                                    cx,
299                                                );
300                                            }
301                                        },
302                                    ),
303                                    MouseEventHandler::new::<Accept, _>(ix, cx, |state, _| {
304                                        let button = style.button.style_for(state);
305                                        Label::new("Accept", button.text.clone())
306                                            .contained()
307                                            .with_style(button.container)
308                                    })
309                                    .with_cursor_style(CursorStyle::PointingHand)
310                                    .on_click(
311                                        MouseButton::Left,
312                                        {
313                                            let notification = notification.clone();
314                                            move |_, view, cx| {
315                                                view.respond_to_notification(
316                                                    notification.clone(),
317                                                    true,
318                                                    cx,
319                                                );
320                                            }
321                                        },
322                                    ),
323                                ])
324                                .aligned()
325                                .right()
326                                .into_any(),
327                        )
328                    } else {
329                        None
330                    })
331                    .contained()
332                    .with_style(container)
333                    .into_any()
334            })
335            .into_any(),
336        )
337    }
338
339    fn render_sign_in_prompt(
340        &self,
341        theme: &Arc<Theme>,
342        cx: &mut ViewContext<Self>,
343    ) -> AnyElement<Self> {
344        enum SignInPromptLabel {}
345
346        MouseEventHandler::new::<SignInPromptLabel, _>(0, cx, |mouse_state, _| {
347            Label::new(
348                "Sign in to view your notifications".to_string(),
349                theme
350                    .chat_panel
351                    .sign_in_prompt
352                    .style_for(mouse_state)
353                    .clone(),
354            )
355        })
356        .with_cursor_style(CursorStyle::PointingHand)
357        .on_click(MouseButton::Left, move |_, this, cx| {
358            let client = this.client.clone();
359            cx.spawn(|_, cx| async move {
360                client.authenticate_and_connect(true, &cx).log_err().await;
361            })
362            .detach();
363        })
364        .aligned()
365        .into_any()
366    }
367
368    fn render_empty_state(
369        &self,
370        theme: &Arc<Theme>,
371        _cx: &mut ViewContext<Self>,
372    ) -> AnyElement<Self> {
373        Label::new(
374            "You have no notifications".to_string(),
375            theme.chat_panel.sign_in_prompt.default.clone(),
376        )
377        .aligned()
378        .into_any()
379    }
380
381    fn on_notification_event(
382        &mut self,
383        _: ModelHandle<NotificationStore>,
384        event: &NotificationEvent,
385        cx: &mut ViewContext<Self>,
386    ) {
387        match event {
388            NotificationEvent::NewNotification { entry } => self.add_toast(entry, cx),
389            NotificationEvent::NotificationRemoved { entry }
390            | NotificationEvent::NotificationRead { entry } => self.remove_toast(entry, cx),
391            NotificationEvent::NotificationsUpdated {
392                old_range,
393                new_count,
394            } => {
395                self.notification_list.splice(old_range.clone(), *new_count);
396                cx.notify();
397            }
398        }
399    }
400
401    fn add_toast(&mut self, entry: &NotificationEntry, cx: &mut ViewContext<Self>) {
402        let id = entry.id as usize;
403        match entry.notification {
404            Notification::ContactRequest {
405                sender_id: actor_id,
406            }
407            | Notification::ContactRequestAccepted {
408                responder_id: actor_id,
409            } => {
410                let user_store = self.user_store.clone();
411                let Some(user) = user_store.read(cx).get_cached_user(actor_id) else {
412                    return;
413                };
414                self.workspace
415                    .update(cx, |workspace, cx| {
416                        workspace.show_notification(id, cx, |cx| {
417                            cx.add_view(|_| {
418                                ContactNotification::new(
419                                    user,
420                                    entry.notification.clone(),
421                                    user_store,
422                                )
423                            })
424                        })
425                    })
426                    .ok();
427            }
428            Notification::ChannelInvitation { .. } => {}
429            Notification::ChannelMessageMention { .. } => {}
430        }
431    }
432
433    fn remove_toast(&mut self, entry: &NotificationEntry, cx: &mut ViewContext<Self>) {
434        let id = entry.id as usize;
435        match entry.notification {
436            Notification::ContactRequest { .. } | Notification::ContactRequestAccepted { .. } => {
437                self.workspace
438                    .update(cx, |workspace, cx| {
439                        workspace.dismiss_notification::<ContactNotification>(id, cx)
440                    })
441                    .ok();
442            }
443            Notification::ChannelInvitation { .. } => {}
444            Notification::ChannelMessageMention { .. } => {}
445        }
446    }
447
448    fn respond_to_notification(
449        &mut self,
450        notification: Notification,
451        response: bool,
452        cx: &mut ViewContext<Self>,
453    ) {
454        self.notification_store.update(cx, |store, cx| {
455            store.respond_to_notification(notification, response, cx);
456        });
457    }
458}
459
460impl Entity for NotificationPanel {
461    type Event = Event;
462}
463
464impl View for NotificationPanel {
465    fn ui_name() -> &'static str {
466        "NotificationPanel"
467    }
468
469    fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
470        let theme = theme::current(cx);
471        let element = if self.client.user_id().is_none() {
472            self.render_sign_in_prompt(&theme, cx)
473        } else if self.notification_list.item_count() == 0 {
474            self.render_empty_state(&theme, cx)
475        } else {
476            List::new(self.notification_list.clone())
477                .contained()
478                .with_style(theme.chat_panel.list)
479                .into_any()
480        };
481        element
482            .contained()
483            .with_style(theme.chat_panel.container)
484            .constrained()
485            .with_min_width(150.)
486            .into_any()
487    }
488
489    fn focus_in(&mut self, _: AnyViewHandle, _: &mut ViewContext<Self>) {
490        self.has_focus = true;
491    }
492
493    fn focus_out(&mut self, _: AnyViewHandle, _: &mut ViewContext<Self>) {
494        self.has_focus = false;
495    }
496}
497
498impl Panel for NotificationPanel {
499    fn position(&self, cx: &gpui::WindowContext) -> DockPosition {
500        settings::get::<NotificationPanelSettings>(cx).dock
501    }
502
503    fn position_is_valid(&self, position: DockPosition) -> bool {
504        matches!(position, DockPosition::Left | DockPosition::Right)
505    }
506
507    fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
508        settings::update_settings_file::<NotificationPanelSettings>(
509            self.fs.clone(),
510            cx,
511            move |settings| settings.dock = Some(position),
512        );
513    }
514
515    fn size(&self, cx: &gpui::WindowContext) -> f32 {
516        self.width
517            .unwrap_or_else(|| settings::get::<NotificationPanelSettings>(cx).default_width)
518    }
519
520    fn set_size(&mut self, size: Option<f32>, cx: &mut ViewContext<Self>) {
521        self.width = size;
522        self.serialize(cx);
523        cx.notify();
524    }
525
526    fn set_active(&mut self, active: bool, cx: &mut ViewContext<Self>) {
527        self.active = active;
528        if active {
529            if !is_channels_feature_enabled(cx) {
530                cx.emit(Event::Dismissed);
531            }
532        }
533    }
534
535    fn icon_path(&self, cx: &gpui::WindowContext) -> Option<&'static str> {
536        (settings::get::<NotificationPanelSettings>(cx).button && is_channels_feature_enabled(cx))
537            .then(|| "icons/bell.svg")
538    }
539
540    fn icon_tooltip(&self) -> (String, Option<Box<dyn gpui::Action>>) {
541        (
542            "Notification Panel".to_string(),
543            Some(Box::new(ToggleFocus)),
544        )
545    }
546
547    fn icon_label(&self, cx: &WindowContext) -> Option<String> {
548        let count = self.notification_store.read(cx).unread_notification_count();
549        if count == 0 {
550            None
551        } else {
552            Some(count.to_string())
553        }
554    }
555
556    fn should_change_position_on_event(event: &Self::Event) -> bool {
557        matches!(event, Event::DockPositionChanged)
558    }
559
560    fn should_close_on_event(event: &Self::Event) -> bool {
561        matches!(event, Event::Dismissed)
562    }
563
564    fn has_focus(&self, _cx: &gpui::WindowContext) -> bool {
565        self.has_focus
566    }
567
568    fn is_focus_event(event: &Self::Event) -> bool {
569        matches!(event, Event::Focus)
570    }
571}
572
573fn render_icon_button<V: View>(style: &IconButton, svg_path: &'static str) -> impl Element<V> {
574    Svg::new(svg_path)
575        .with_color(style.color)
576        .constrained()
577        .with_width(style.icon_width)
578        .aligned()
579        .constrained()
580        .with_width(style.button_width)
581        .with_height(style.button_width)
582        .contained()
583        .with_style(style.container)
584}