notification_panel.rs

  1use crate::{NotificationPanelSettings, chat_panel::ChatPanel};
  2use anyhow::Result;
  3use channel::ChannelStore;
  4use client::{ChannelId, Client, Notification, User, UserStore};
  5use collections::HashMap;
  6use db::kvp::KEY_VALUE_STORE;
  7use futures::StreamExt;
  8use gpui::{
  9    AnyElement, App, AsyncWindowContext, Context, CursorStyle, DismissEvent, Element, Entity,
 10    EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, ListAlignment,
 11    ListScrollEvent, ListState, ParentElement, Render, StatefulInteractiveElement, Styled, Task,
 12    WeakEntity, Window, actions, div, img, list, px,
 13};
 14use notifications::{NotificationEntry, NotificationEvent, NotificationStore};
 15use project::Fs;
 16use rpc::proto;
 17use serde::{Deserialize, Serialize};
 18use settings::{Settings, SettingsStore};
 19use std::{sync::Arc, time::Duration};
 20use time::{OffsetDateTime, UtcOffset};
 21use ui::{
 22    Avatar, Button, Icon, IconButton, IconName, Label, Tab, Tooltip, h_flex, prelude::*, v_flex,
 23};
 24use util::{ResultExt, TryFutureExt};
 25use workspace::notifications::{Notification as WorkspaceNotification, NotificationId};
 26use workspace::{
 27    Workspace,
 28    dock::{DockPosition, Panel, PanelEvent},
 29};
 30
 31const LOADING_THRESHOLD: usize = 30;
 32const MARK_AS_READ_DELAY: Duration = Duration::from_secs(1);
 33const TOAST_DURATION: Duration = Duration::from_secs(5);
 34const NOTIFICATION_PANEL_KEY: &str = "NotificationPanel";
 35
 36pub struct NotificationPanel {
 37    client: Arc<Client>,
 38    user_store: Entity<UserStore>,
 39    channel_store: Entity<ChannelStore>,
 40    notification_store: Entity<NotificationStore>,
 41    fs: Arc<dyn Fs>,
 42    width: Option<Pixels>,
 43    active: bool,
 44    notification_list: ListState,
 45    pending_serialization: Task<Option<()>>,
 46    subscriptions: Vec<gpui::Subscription>,
 47    workspace: WeakEntity<Workspace>,
 48    current_notification_toast: Option<(u64, Task<()>)>,
 49    local_timezone: UtcOffset,
 50    focus_handle: FocusHandle,
 51    mark_as_read_tasks: HashMap<u64, Task<Result<()>>>,
 52    unseen_notifications: Vec<NotificationEntry>,
 53}
 54
 55#[derive(Serialize, Deserialize)]
 56struct SerializedNotificationPanel {
 57    width: Option<Pixels>,
 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 App) {
 78    cx.observe_new(|workspace: &mut Workspace, _, _| {
 79        workspace.register_action(|workspace, _: &ToggleFocus, window, cx| {
 80            workspace.toggle_panel_focus::<NotificationPanel>(window, cx);
 81        });
 82    })
 83    .detach();
 84}
 85
 86impl NotificationPanel {
 87    pub fn new(
 88        workspace: &mut Workspace,
 89        window: &mut Window,
 90        cx: &mut Context<Workspace>,
 91    ) -> Entity<Self> {
 92        let fs = workspace.app_state().fs.clone();
 93        let client = workspace.app_state().client.clone();
 94        let user_store = workspace.app_state().user_store.clone();
 95        let workspace_handle = workspace.weak_handle();
 96
 97        cx.new(|cx| {
 98            let mut status = client.status();
 99            cx.spawn_in(window, async move |this, cx| {
100                while (status.next().await).is_some() {
101                    if this
102                        .update(cx, |_: &mut Self, cx| {
103                            cx.notify();
104                        })
105                        .is_err()
106                    {
107                        break;
108                    }
109                }
110            })
111            .detach();
112
113            let entity = cx.entity().downgrade();
114            let notification_list =
115                ListState::new(0, ListAlignment::Top, px(1000.), move |ix, window, cx| {
116                    entity
117                        .upgrade()
118                        .and_then(|entity| {
119                            entity.update(cx, |this, cx| this.render_notification(ix, window, cx))
120                        })
121                        .unwrap_or_else(|| div().into_any())
122                });
123            notification_list.set_scroll_handler(cx.listener(
124                |this, event: &ListScrollEvent, _, cx| {
125                    if event.count.saturating_sub(event.visible_range.end) < LOADING_THRESHOLD {
126                        if let Some(task) = this
127                            .notification_store
128                            .update(cx, |store, cx| store.load_more_notifications(false, cx))
129                        {
130                            task.detach();
131                        }
132                    }
133                },
134            ));
135
136            let local_offset = chrono::Local::now().offset().local_minus_utc();
137            let mut this = Self {
138                fs,
139                client,
140                user_store,
141                local_timezone: UtcOffset::from_whole_seconds(local_offset).unwrap(),
142                channel_store: ChannelStore::global(cx),
143                notification_store: NotificationStore::global(cx),
144                notification_list,
145                pending_serialization: Task::ready(None),
146                workspace: workspace_handle,
147                focus_handle: cx.focus_handle(),
148                current_notification_toast: None,
149                subscriptions: Vec::new(),
150                active: false,
151                mark_as_read_tasks: HashMap::default(),
152                width: None,
153                unseen_notifications: Vec::new(),
154            };
155
156            let mut old_dock_position = this.position(window, cx);
157            this.subscriptions.extend([
158                cx.observe(&this.notification_store, |_, _, cx| cx.notify()),
159                cx.subscribe_in(
160                    &this.notification_store,
161                    window,
162                    Self::on_notification_event,
163                ),
164                cx.observe_global_in::<SettingsStore>(
165                    window,
166                    move |this: &mut Self, window, cx| {
167                        let new_dock_position = this.position(window, cx);
168                        if new_dock_position != old_dock_position {
169                            old_dock_position = new_dock_position;
170                            cx.emit(Event::DockPositionChanged);
171                        }
172                        cx.notify();
173                    },
174                ),
175            ]);
176            this
177        })
178    }
179
180    pub fn load(
181        workspace: WeakEntity<Workspace>,
182        cx: AsyncWindowContext,
183    ) -> Task<Result<Entity<Self>>> {
184        cx.spawn(async move |cx| {
185            let serialized_panel = if let Some(panel) = cx
186                .background_spawn(async move { KEY_VALUE_STORE.read_kvp(NOTIFICATION_PANEL_KEY) })
187                .await
188                .log_err()
189                .flatten()
190            {
191                Some(serde_json::from_str::<SerializedNotificationPanel>(&panel)?)
192            } else {
193                None
194            };
195
196            workspace.update_in(cx, |workspace, window, cx| {
197                let panel = Self::new(workspace, window, cx);
198                if let Some(serialized_panel) = serialized_panel {
199                    panel.update(cx, |panel, cx| {
200                        panel.width = serialized_panel.width.map(|w| w.round());
201                        cx.notify();
202                    });
203                }
204                panel
205            })
206        })
207    }
208
209    fn serialize(&mut self, cx: &mut Context<Self>) {
210        let width = self.width;
211        self.pending_serialization = cx.background_spawn(
212            async move {
213                KEY_VALUE_STORE
214                    .write_kvp(
215                        NOTIFICATION_PANEL_KEY.into(),
216                        serde_json::to_string(&SerializedNotificationPanel { width })?,
217                    )
218                    .await?;
219                anyhow::Ok(())
220            }
221            .log_err(),
222        );
223    }
224
225    fn render_notification(
226        &mut self,
227        ix: usize,
228        window: &mut Window,
229        cx: &mut Context<Self>,
230    ) -> Option<AnyElement> {
231        let entry = self.notification_store.read(cx).notification_at(ix)?;
232        let notification_id = entry.id;
233        let now = OffsetDateTime::now_utc();
234        let timestamp = entry.timestamp;
235        let NotificationPresenter {
236            actor,
237            text,
238            needs_response,
239            can_navigate,
240            ..
241        } = self.present_notification(entry, cx)?;
242
243        let response = entry.response;
244        let notification = entry.notification.clone();
245
246        if self.active && !entry.is_read {
247            self.did_render_notification(notification_id, &notification, window, cx);
248        }
249
250        let relative_timestamp = time_format::format_localized_timestamp(
251            timestamp,
252            now,
253            self.local_timezone,
254            time_format::TimestampFormat::Relative,
255        );
256
257        let absolute_timestamp = time_format::format_localized_timestamp(
258            timestamp,
259            now,
260            self.local_timezone,
261            time_format::TimestampFormat::Absolute,
262        );
263
264        Some(
265            div()
266                .id(ix)
267                .flex()
268                .flex_row()
269                .size_full()
270                .px_2()
271                .py_1()
272                .gap_2()
273                .hover(|style| style.bg(cx.theme().colors().element_hover))
274                .when(can_navigate, |el| {
275                    el.cursor(CursorStyle::PointingHand).on_click({
276                        let notification = notification.clone();
277                        cx.listener(move |this, _, window, cx| {
278                            this.did_click_notification(&notification, window, cx)
279                        })
280                    })
281                })
282                .children(actor.map(|actor| {
283                    img(actor.avatar_uri.clone())
284                        .flex_none()
285                        .w_8()
286                        .h_8()
287                        .rounded_full()
288                }))
289                .child(
290                    v_flex()
291                        .gap_1()
292                        .size_full()
293                        .overflow_hidden()
294                        .child(Label::new(text.clone()))
295                        .child(
296                            h_flex()
297                                .child(
298                                    div()
299                                        .id("notification_timestamp")
300                                        .hover(|style| {
301                                            style
302                                                .bg(cx.theme().colors().element_selected)
303                                                .rounded_sm()
304                                        })
305                                        .child(Label::new(relative_timestamp).color(Color::Muted))
306                                        .tooltip(move |_, cx| {
307                                            Tooltip::simple(absolute_timestamp.clone(), cx)
308                                        }),
309                                )
310                                .children(if let Some(is_accepted) = response {
311                                    Some(div().flex().flex_grow().justify_end().child(Label::new(
312                                        if is_accepted {
313                                            "You accepted"
314                                        } else {
315                                            "You declined"
316                                        },
317                                    )))
318                                } else if needs_response {
319                                    Some(
320                                        h_flex()
321                                            .flex_grow()
322                                            .justify_end()
323                                            .child(Button::new("decline", "Decline").on_click({
324                                                let notification = notification.clone();
325                                                let entity = cx.entity().clone();
326                                                move |_, _, cx| {
327                                                    entity.update(cx, |this, cx| {
328                                                        this.respond_to_notification(
329                                                            notification.clone(),
330                                                            false,
331                                                            cx,
332                                                        )
333                                                    });
334                                                }
335                                            }))
336                                            .child(Button::new("accept", "Accept").on_click({
337                                                let notification = notification.clone();
338                                                let entity = cx.entity().clone();
339                                                move |_, _, cx| {
340                                                    entity.update(cx, |this, cx| {
341                                                        this.respond_to_notification(
342                                                            notification.clone(),
343                                                            true,
344                                                            cx,
345                                                        )
346                                                    });
347                                                }
348                                            })),
349                                    )
350                                } else {
351                                    None
352                                }),
353                        ),
354                )
355                .into_any(),
356        )
357    }
358
359    fn present_notification(
360        &self,
361        entry: &NotificationEntry,
362        cx: &App,
363    ) -> Option<NotificationPresenter> {
364        let user_store = self.user_store.read(cx);
365        let channel_store = self.channel_store.read(cx);
366        match entry.notification {
367            Notification::ContactRequest { sender_id } => {
368                let requester = user_store.get_cached_user(sender_id)?;
369                Some(NotificationPresenter {
370                    icon: "icons/plus.svg",
371                    text: format!("{} wants to add you as a contact", requester.github_login),
372                    needs_response: user_store.has_incoming_contact_request(requester.id),
373                    actor: Some(requester),
374                    can_navigate: false,
375                })
376            }
377            Notification::ContactRequestAccepted { responder_id } => {
378                let responder = user_store.get_cached_user(responder_id)?;
379                Some(NotificationPresenter {
380                    icon: "icons/plus.svg",
381                    text: format!("{} accepted your contact invite", responder.github_login),
382                    needs_response: false,
383                    actor: Some(responder),
384                    can_navigate: false,
385                })
386            }
387            Notification::ChannelInvitation {
388                ref channel_name,
389                channel_id,
390                inviter_id,
391            } => {
392                let inviter = user_store.get_cached_user(inviter_id)?;
393                Some(NotificationPresenter {
394                    icon: "icons/hash.svg",
395                    text: format!(
396                        "{} invited you to join the #{channel_name} channel",
397                        inviter.github_login
398                    ),
399                    needs_response: channel_store.has_channel_invitation(ChannelId(channel_id)),
400                    actor: Some(inviter),
401                    can_navigate: false,
402                })
403            }
404            Notification::ChannelMessageMention {
405                sender_id,
406                channel_id,
407                message_id,
408            } => {
409                let sender = user_store.get_cached_user(sender_id)?;
410                let channel = channel_store.channel_for_id(ChannelId(channel_id))?;
411                let message = self
412                    .notification_store
413                    .read(cx)
414                    .channel_message_for_id(message_id)?;
415                Some(NotificationPresenter {
416                    icon: "icons/conversations.svg",
417                    text: format!(
418                        "{} mentioned you in #{}:\n{}",
419                        sender.github_login, channel.name, message.body,
420                    ),
421                    needs_response: false,
422                    actor: Some(sender),
423                    can_navigate: true,
424                })
425            }
426        }
427    }
428
429    fn did_render_notification(
430        &mut self,
431        notification_id: u64,
432        notification: &Notification,
433        window: &mut Window,
434        cx: &mut Context<Self>,
435    ) {
436        let should_mark_as_read = match notification {
437            Notification::ContactRequestAccepted { .. } => true,
438            Notification::ContactRequest { .. }
439            | Notification::ChannelInvitation { .. }
440            | Notification::ChannelMessageMention { .. } => false,
441        };
442
443        if should_mark_as_read {
444            self.mark_as_read_tasks
445                .entry(notification_id)
446                .or_insert_with(|| {
447                    let client = self.client.clone();
448                    cx.spawn_in(window, async move |this, cx| {
449                        cx.background_executor().timer(MARK_AS_READ_DELAY).await;
450                        client
451                            .request(proto::MarkNotificationRead { notification_id })
452                            .await?;
453                        this.update(cx, |this, _| {
454                            this.mark_as_read_tasks.remove(&notification_id);
455                        })?;
456                        Ok(())
457                    })
458                });
459        }
460    }
461
462    fn did_click_notification(
463        &mut self,
464        notification: &Notification,
465        window: &mut Window,
466        cx: &mut Context<Self>,
467    ) {
468        if let Notification::ChannelMessageMention {
469            message_id,
470            channel_id,
471            ..
472        } = notification.clone()
473        {
474            if let Some(workspace) = self.workspace.upgrade() {
475                window.defer(cx, move |window, cx| {
476                    workspace.update(cx, |workspace, cx| {
477                        if let Some(panel) = workspace.focus_panel::<ChatPanel>(window, cx) {
478                            panel.update(cx, |panel, cx| {
479                                panel
480                                    .select_channel(ChannelId(channel_id), Some(message_id), cx)
481                                    .detach_and_log_err(cx);
482                            });
483                        }
484                    });
485                });
486            }
487        }
488    }
489
490    fn is_showing_notification(&self, notification: &Notification, cx: &mut Context<Self>) -> bool {
491        if !self.active {
492            return false;
493        }
494
495        if let Notification::ChannelMessageMention { channel_id, .. } = &notification {
496            if let Some(workspace) = self.workspace.upgrade() {
497                return if let Some(panel) = workspace.read(cx).panel::<ChatPanel>(cx) {
498                    let panel = panel.read(cx);
499                    panel.is_scrolled_to_bottom()
500                        && panel
501                            .active_chat()
502                            .map_or(false, |chat| chat.read(cx).channel_id.0 == *channel_id)
503                } else {
504                    false
505                };
506            }
507        }
508
509        false
510    }
511
512    fn on_notification_event(
513        &mut self,
514        _: &Entity<NotificationStore>,
515        event: &NotificationEvent,
516        window: &mut Window,
517        cx: &mut Context<Self>,
518    ) {
519        match event {
520            NotificationEvent::NewNotification { entry } => {
521                if !self.is_showing_notification(&entry.notification, cx) {
522                    self.unseen_notifications.push(entry.clone());
523                }
524                self.add_toast(entry, window, cx);
525            }
526            NotificationEvent::NotificationRemoved { entry }
527            | NotificationEvent::NotificationRead { entry } => {
528                self.unseen_notifications.retain(|n| n.id != entry.id);
529                self.remove_toast(entry.id, cx);
530            }
531            NotificationEvent::NotificationsUpdated {
532                old_range,
533                new_count,
534            } => {
535                self.notification_list.splice(old_range.clone(), *new_count);
536                cx.notify();
537            }
538        }
539    }
540
541    fn add_toast(
542        &mut self,
543        entry: &NotificationEntry,
544        window: &mut Window,
545        cx: &mut Context<Self>,
546    ) {
547        if self.is_showing_notification(&entry.notification, cx) {
548            return;
549        }
550
551        let Some(NotificationPresenter { actor, text, .. }) = self.present_notification(entry, cx)
552        else {
553            return;
554        };
555
556        let notification_id = entry.id;
557        self.current_notification_toast = Some((
558            notification_id,
559            cx.spawn_in(window, async move |this, cx| {
560                cx.background_executor().timer(TOAST_DURATION).await;
561                this.update(cx, |this, cx| this.remove_toast(notification_id, cx))
562                    .ok();
563            }),
564        ));
565
566        self.workspace
567            .update(cx, |workspace, cx| {
568                let id = NotificationId::unique::<NotificationToast>();
569
570                workspace.dismiss_notification(&id, cx);
571                workspace.show_notification(id, cx, |cx| {
572                    let workspace = cx.entity().downgrade();
573                    cx.new(|cx| NotificationToast {
574                        notification_id,
575                        actor,
576                        text,
577                        workspace,
578                        focus_handle: cx.focus_handle(),
579                    })
580                })
581            })
582            .ok();
583    }
584
585    fn remove_toast(&mut self, notification_id: u64, cx: &mut Context<Self>) {
586        if let Some((current_id, _)) = &self.current_notification_toast {
587            if *current_id == notification_id {
588                self.current_notification_toast.take();
589                self.workspace
590                    .update(cx, |workspace, cx| {
591                        let id = NotificationId::unique::<NotificationToast>();
592                        workspace.dismiss_notification(&id, cx)
593                    })
594                    .ok();
595            }
596        }
597    }
598
599    fn respond_to_notification(
600        &mut self,
601        notification: Notification,
602        response: bool,
603
604        cx: &mut Context<Self>,
605    ) {
606        self.notification_store.update(cx, |store, cx| {
607            store.respond_to_notification(notification, response, cx);
608        });
609    }
610}
611
612impl Render for NotificationPanel {
613    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
614        v_flex()
615            .size_full()
616            .child(
617                h_flex()
618                    .justify_between()
619                    .px_2()
620                    .py_1()
621                    // Match the height of the tab bar so they line up.
622                    .h(Tab::container_height(cx))
623                    .border_b_1()
624                    .border_color(cx.theme().colors().border)
625                    .child(Label::new("Notifications"))
626                    .child(Icon::new(IconName::Envelope)),
627            )
628            .map(|this| {
629                if self.client.user_id().is_none() {
630                    this.child(
631                        v_flex()
632                            .gap_2()
633                            .p_4()
634                            .child(
635                                Button::new("sign_in_prompt_button", "Sign in")
636                                    .icon_color(Color::Muted)
637                                    .icon(IconName::Github)
638                                    .icon_position(IconPosition::Start)
639                                    .style(ButtonStyle::Filled)
640                                    .full_width()
641                                    .on_click({
642                                        let client = self.client.clone();
643                                        move |_, window, cx| {
644                                            let client = client.clone();
645                                            window
646                                                .spawn(cx, async move |cx| {
647                                                    client
648                                                        .authenticate_and_connect(true, &cx)
649                                                        .log_err()
650                                                        .await;
651                                                })
652                                                .detach()
653                                        }
654                                    }),
655                            )
656                            .child(
657                                div().flex().w_full().items_center().child(
658                                    Label::new("Sign in to view notifications.")
659                                        .color(Color::Muted)
660                                        .size(LabelSize::Small),
661                                ),
662                            ),
663                    )
664                } else if self.notification_list.item_count() == 0 {
665                    this.child(
666                        v_flex().p_4().child(
667                            div().flex().w_full().items_center().child(
668                                Label::new("You have no notifications.")
669                                    .color(Color::Muted)
670                                    .size(LabelSize::Small),
671                            ),
672                        ),
673                    )
674                } else {
675                    this.child(list(self.notification_list.clone()).size_full())
676                }
677            })
678    }
679}
680
681impl Focusable for NotificationPanel {
682    fn focus_handle(&self, _: &App) -> FocusHandle {
683        self.focus_handle.clone()
684    }
685}
686
687impl EventEmitter<Event> for NotificationPanel {}
688impl EventEmitter<PanelEvent> for NotificationPanel {}
689
690impl Panel for NotificationPanel {
691    fn persistent_name() -> &'static str {
692        "NotificationPanel"
693    }
694
695    fn position(&self, _: &Window, cx: &App) -> DockPosition {
696        NotificationPanelSettings::get_global(cx).dock
697    }
698
699    fn position_is_valid(&self, position: DockPosition) -> bool {
700        matches!(position, DockPosition::Left | DockPosition::Right)
701    }
702
703    fn set_position(&mut self, position: DockPosition, _: &mut Window, cx: &mut Context<Self>) {
704        settings::update_settings_file::<NotificationPanelSettings>(
705            self.fs.clone(),
706            cx,
707            move |settings, _| settings.dock = Some(position),
708        );
709    }
710
711    fn size(&self, _: &Window, cx: &App) -> Pixels {
712        self.width
713            .unwrap_or_else(|| NotificationPanelSettings::get_global(cx).default_width)
714    }
715
716    fn set_size(&mut self, size: Option<Pixels>, _: &mut Window, cx: &mut Context<Self>) {
717        self.width = size;
718        self.serialize(cx);
719        cx.notify();
720    }
721
722    fn set_active(&mut self, active: bool, _: &mut Window, cx: &mut Context<Self>) {
723        self.active = active;
724
725        if self.active {
726            self.unseen_notifications = Vec::new();
727            cx.notify();
728        }
729
730        if self.notification_store.read(cx).notification_count() == 0 {
731            cx.emit(Event::Dismissed);
732        }
733    }
734
735    fn icon(&self, _: &Window, cx: &App) -> Option<IconName> {
736        let show_button = NotificationPanelSettings::get_global(cx).button;
737        if !show_button {
738            return None;
739        }
740
741        if self.unseen_notifications.is_empty() {
742            return Some(IconName::Bell);
743        }
744
745        Some(IconName::BellDot)
746    }
747
748    fn icon_tooltip(&self, _window: &Window, _cx: &App) -> Option<&'static str> {
749        Some("Notification Panel")
750    }
751
752    fn icon_label(&self, _window: &Window, cx: &App) -> Option<String> {
753        let count = self.notification_store.read(cx).unread_notification_count();
754        if count == 0 {
755            None
756        } else {
757            Some(count.to_string())
758        }
759    }
760
761    fn toggle_action(&self) -> Box<dyn gpui::Action> {
762        Box::new(ToggleFocus)
763    }
764
765    fn activation_priority(&self) -> u32 {
766        8
767    }
768}
769
770pub struct NotificationToast {
771    notification_id: u64,
772    actor: Option<Arc<User>>,
773    text: String,
774    workspace: WeakEntity<Workspace>,
775    focus_handle: FocusHandle,
776}
777
778impl Focusable for NotificationToast {
779    fn focus_handle(&self, _cx: &App) -> FocusHandle {
780        self.focus_handle.clone()
781    }
782}
783
784impl WorkspaceNotification for NotificationToast {}
785
786impl NotificationToast {
787    fn focus_notification_panel(&self, window: &mut Window, cx: &mut Context<Self>) {
788        let workspace = self.workspace.clone();
789        let notification_id = self.notification_id;
790        window.defer(cx, move |window, cx| {
791            workspace
792                .update(cx, |workspace, cx| {
793                    if let Some(panel) = workspace.focus_panel::<NotificationPanel>(window, cx) {
794                        panel.update(cx, |panel, cx| {
795                            let store = panel.notification_store.read(cx);
796                            if let Some(entry) = store.notification_for_id(notification_id) {
797                                panel.did_click_notification(
798                                    &entry.clone().notification,
799                                    window,
800                                    cx,
801                                );
802                            }
803                        });
804                    }
805                })
806                .ok();
807        })
808    }
809}
810
811impl Render for NotificationToast {
812    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
813        let user = self.actor.clone();
814
815        h_flex()
816            .id("notification_panel_toast")
817            .elevation_3(cx)
818            .p_2()
819            .gap_2()
820            .children(user.map(|user| Avatar::new(user.avatar_uri.clone())))
821            .child(Label::new(self.text.clone()))
822            .child(
823                IconButton::new("close", IconName::Close)
824                    .on_click(cx.listener(|_, _, _, cx| cx.emit(DismissEvent))),
825            )
826            .on_click(cx.listener(|this, _, window, cx| {
827                this.focus_notification_panel(window, cx);
828                cx.emit(DismissEvent);
829            }))
830    }
831}
832
833impl EventEmitter<DismissEvent> for NotificationToast {}