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 now = OffsetDateTime::now_utc();
187        let timestamp = entry.timestamp;
188
189        let icon;
190        let text;
191        let actor;
192        match entry.notification {
193            Notification::ContactRequest {
194                actor_id: requester_id,
195            } => {
196                actor = user_store.get_cached_user(requester_id)?;
197                icon = "icons/plus.svg";
198                text = format!("{} wants to add you as a contact", actor.github_login);
199            }
200            Notification::ContactRequestAccepted {
201                actor_id: contact_id,
202            } => {
203                actor = user_store.get_cached_user(contact_id)?;
204                icon = "icons/plus.svg";
205                text = format!("{} accepted your contact invite", actor.github_login);
206            }
207            Notification::ChannelInvitation {
208                actor_id: inviter_id,
209                channel_id,
210            } => {
211                actor = user_store.get_cached_user(inviter_id)?;
212                let channel = channel_store.channel_for_id(channel_id).or_else(|| {
213                    channel_store
214                        .channel_invitations()
215                        .iter()
216                        .find(|c| c.id == channel_id)
217                })?;
218
219                icon = "icons/hash.svg";
220                text = format!(
221                    "{} invited you to join the #{} channel",
222                    actor.github_login, channel.name
223                );
224            }
225            Notification::ChannelMessageMention {
226                actor_id: sender_id,
227                channel_id,
228                message_id,
229            } => {
230                actor = user_store.get_cached_user(sender_id)?;
231                let channel = channel_store.channel_for_id(channel_id)?;
232                let message = notification_store.channel_message_for_id(message_id)?;
233
234                icon = "icons/conversations.svg";
235                text = format!(
236                    "{} mentioned you in the #{} channel:\n{}",
237                    actor.github_login, channel.name, message.body,
238                );
239            }
240        }
241
242        let theme = theme::current(cx);
243        let style = &theme.chat_panel.message;
244
245        Some(
246            MouseEventHandler::new::<NotificationEntry, _>(ix, cx, |state, _| {
247                let container = style.container.style_for(state);
248
249                Flex::column()
250                    .with_child(
251                        Flex::row()
252                            .with_child(render_avatar(actor.avatar.clone(), &theme))
253                            .with_child(render_icon_button(&theme.chat_panel.icon_button, icon))
254                            .with_child(
255                                Label::new(
256                                    format_timestamp(timestamp, now, self.local_timezone),
257                                    style.timestamp.text.clone(),
258                                )
259                                .contained()
260                                .with_style(style.timestamp.container),
261                            )
262                            .align_children_center(),
263                    )
264                    .with_child(Text::new(text, style.body.clone()))
265                    .contained()
266                    .with_style(*container)
267                    .into_any()
268            })
269            .into_any(),
270        )
271    }
272
273    fn render_sign_in_prompt(
274        &self,
275        theme: &Arc<Theme>,
276        cx: &mut ViewContext<Self>,
277    ) -> AnyElement<Self> {
278        enum SignInPromptLabel {}
279
280        MouseEventHandler::new::<SignInPromptLabel, _>(0, cx, |mouse_state, _| {
281            Label::new(
282                "Sign in to view your notifications".to_string(),
283                theme
284                    .chat_panel
285                    .sign_in_prompt
286                    .style_for(mouse_state)
287                    .clone(),
288            )
289        })
290        .with_cursor_style(CursorStyle::PointingHand)
291        .on_click(MouseButton::Left, move |_, this, cx| {
292            let client = this.client.clone();
293            cx.spawn(|_, cx| async move {
294                client.authenticate_and_connect(true, &cx).log_err().await;
295            })
296            .detach();
297        })
298        .aligned()
299        .into_any()
300    }
301
302    fn on_notification_event(
303        &mut self,
304        _: ModelHandle<NotificationStore>,
305        event: &NotificationEvent,
306        cx: &mut ViewContext<Self>,
307    ) {
308        match event {
309            NotificationEvent::NewNotification { entry } => self.add_toast(entry, cx),
310            NotificationEvent::NotificationRemoved { entry } => self.remove_toast(entry, cx),
311            NotificationEvent::NotificationsUpdated {
312                old_range,
313                new_count,
314            } => {
315                self.notification_list.splice(old_range.clone(), *new_count);
316                cx.notify();
317            }
318        }
319    }
320
321    fn add_toast(&mut self, entry: &NotificationEntry, cx: &mut ViewContext<Self>) {
322        let id = entry.id as usize;
323        match entry.notification {
324            Notification::ContactRequest { actor_id }
325            | Notification::ContactRequestAccepted { actor_id } => {
326                let user_store = self.user_store.clone();
327                let Some(user) = user_store.read(cx).get_cached_user(actor_id) else {
328                    return;
329                };
330                self.workspace
331                    .update(cx, |workspace, cx| {
332                        workspace.show_notification(id, cx, |cx| {
333                            cx.add_view(|_| {
334                                ContactNotification::new(
335                                    user,
336                                    entry.notification.clone(),
337                                    user_store,
338                                )
339                            })
340                        })
341                    })
342                    .ok();
343            }
344            Notification::ChannelInvitation { .. } => {}
345            Notification::ChannelMessageMention { .. } => {}
346        }
347    }
348
349    fn remove_toast(&mut self, entry: &NotificationEntry, cx: &mut ViewContext<Self>) {
350        let id = entry.id as usize;
351        match entry.notification {
352            Notification::ContactRequest { .. } | Notification::ContactRequestAccepted { .. } => {
353                self.workspace
354                    .update(cx, |workspace, cx| {
355                        workspace.dismiss_notification::<ContactNotification>(id, cx)
356                    })
357                    .ok();
358            }
359            Notification::ChannelInvitation { .. } => {}
360            Notification::ChannelMessageMention { .. } => {}
361        }
362    }
363}
364
365impl Entity for NotificationPanel {
366    type Event = Event;
367}
368
369impl View for NotificationPanel {
370    fn ui_name() -> &'static str {
371        "NotificationPanel"
372    }
373
374    fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
375        let theme = theme::current(cx);
376        let element = if self.client.user_id().is_some() {
377            List::new(self.notification_list.clone())
378                .contained()
379                .with_style(theme.chat_panel.list)
380                .into_any()
381        } else {
382            self.render_sign_in_prompt(&theme, cx)
383        };
384        element
385            .contained()
386            .with_style(theme.chat_panel.container)
387            .constrained()
388            .with_min_width(150.)
389            .into_any()
390    }
391
392    fn focus_in(&mut self, _: AnyViewHandle, _: &mut ViewContext<Self>) {
393        self.has_focus = true;
394    }
395
396    fn focus_out(&mut self, _: AnyViewHandle, _: &mut ViewContext<Self>) {
397        self.has_focus = false;
398    }
399}
400
401impl Panel for NotificationPanel {
402    fn position(&self, cx: &gpui::WindowContext) -> DockPosition {
403        settings::get::<NotificationPanelSettings>(cx).dock
404    }
405
406    fn position_is_valid(&self, position: DockPosition) -> bool {
407        matches!(position, DockPosition::Left | DockPosition::Right)
408    }
409
410    fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
411        settings::update_settings_file::<NotificationPanelSettings>(
412            self.fs.clone(),
413            cx,
414            move |settings| settings.dock = Some(position),
415        );
416    }
417
418    fn size(&self, cx: &gpui::WindowContext) -> f32 {
419        self.width
420            .unwrap_or_else(|| settings::get::<NotificationPanelSettings>(cx).default_width)
421    }
422
423    fn set_size(&mut self, size: Option<f32>, cx: &mut ViewContext<Self>) {
424        self.width = size;
425        self.serialize(cx);
426        cx.notify();
427    }
428
429    fn set_active(&mut self, active: bool, cx: &mut ViewContext<Self>) {
430        self.active = active;
431        if active {
432            if !is_channels_feature_enabled(cx) {
433                cx.emit(Event::Dismissed);
434            }
435        }
436    }
437
438    fn icon_path(&self, cx: &gpui::WindowContext) -> Option<&'static str> {
439        (settings::get::<NotificationPanelSettings>(cx).button && is_channels_feature_enabled(cx))
440            .then(|| "icons/bell.svg")
441    }
442
443    fn icon_tooltip(&self) -> (String, Option<Box<dyn gpui::Action>>) {
444        (
445            "Notification Panel".to_string(),
446            Some(Box::new(ToggleFocus)),
447        )
448    }
449
450    fn icon_label(&self, cx: &WindowContext) -> Option<String> {
451        let count = self.notification_store.read(cx).unread_notification_count();
452        if count == 0 {
453            None
454        } else {
455            Some(count.to_string())
456        }
457    }
458
459    fn should_change_position_on_event(event: &Self::Event) -> bool {
460        matches!(event, Event::DockPositionChanged)
461    }
462
463    fn should_close_on_event(event: &Self::Event) -> bool {
464        matches!(event, Event::Dismissed)
465    }
466
467    fn has_focus(&self, _cx: &gpui::WindowContext) -> bool {
468        self.has_focus
469    }
470
471    fn is_focus_event(event: &Self::Event) -> bool {
472        matches!(event, Event::Focus)
473    }
474}
475
476fn render_icon_button<V: View>(style: &IconButton, svg_path: &'static str) -> impl Element<V> {
477    Svg::new(svg_path)
478        .with_color(style.color)
479        .constrained()
480        .with_width(style.icon_width)
481        .aligned()
482        .constrained()
483        .with_width(style.button_width)
484        .with_height(style.button_width)
485        .contained()
486        .with_style(style.container)
487}