onboarding_banner.rs

  1use gpui::{Action, Entity, Global, Render, SharedString};
  2use ui::{ButtonLike, Tooltip, prelude::*};
  3use util::ResultExt;
  4
  5/// Prompts the user to try newly released Zed's features
  6pub struct OnboardingBanner {
  7    dismissed: bool,
  8    source: String,
  9    details: BannerDetails,
 10    visible_when: Option<Box<dyn Fn(&mut App) -> bool>>,
 11}
 12
 13#[derive(Clone)]
 14struct BannerGlobal {
 15    entity: Entity<OnboardingBanner>,
 16}
 17impl Global for BannerGlobal {}
 18
 19pub struct BannerDetails {
 20    pub action: Box<dyn Action>,
 21    pub icon_name: IconName,
 22    pub label: SharedString,
 23    pub subtitle: Option<SharedString>,
 24}
 25
 26impl OnboardingBanner {
 27    pub fn new(
 28        source: &str,
 29        icon_name: IconName,
 30        label: impl Into<SharedString>,
 31        subtitle: Option<SharedString>,
 32        action: Box<dyn Action>,
 33        cx: &mut Context<Self>,
 34    ) -> Self {
 35        cx.set_global(BannerGlobal {
 36            entity: cx.entity(),
 37        });
 38        Self {
 39            source: source.to_string(),
 40            details: BannerDetails {
 41                action,
 42                icon_name,
 43                label: label.into(),
 44                subtitle: subtitle.or(Some(SharedString::from("Introducing:"))),
 45            },
 46            visible_when: None,
 47            dismissed: get_dismissed(source, cx),
 48        }
 49    }
 50
 51    pub fn visible_when(mut self, predicate: impl Fn(&mut App) -> bool + 'static) -> Self {
 52        self.visible_when = Some(Box::new(predicate));
 53        self
 54    }
 55
 56    fn should_show(&self, cx: &mut App) -> bool {
 57        !self.dismissed && self.visible_when.as_ref().map_or(true, |f| f(cx))
 58    }
 59
 60    fn dismiss(&mut self, cx: &mut Context<Self>) {
 61        persist_dismissed(&self.source, cx);
 62        self.dismissed = true;
 63        cx.notify();
 64    }
 65}
 66
 67fn dismissed_at_key(source: &str) -> String {
 68    if source == "Git Onboarding" {
 69        "zed_git_banner_dismissed_at".to_string()
 70    } else {
 71        format!(
 72            "{}_banner_dismissed_at",
 73            source.to_lowercase().trim().replace(" ", "_")
 74        )
 75    }
 76}
 77
 78fn get_dismissed(source: &str, cx: &App) -> bool {
 79    let dismissed_at = dismissed_at_key(source);
 80    db::kvp::KeyValueStore::global(cx)
 81        .read_kvp(&dismissed_at)
 82        .log_err()
 83        .is_some_and(|dismissed| dismissed.is_some())
 84}
 85
 86fn persist_dismissed(source: &str, cx: &mut App) {
 87    let dismissed_at = dismissed_at_key(source);
 88    let kvp = db::kvp::KeyValueStore::global(cx);
 89    cx.spawn(async move |_| {
 90        let time = chrono::Utc::now().to_rfc3339();
 91        kvp.write_kvp(dismissed_at, time).await
 92    })
 93    .detach_and_log_err(cx);
 94}
 95
 96pub fn restore_banner(cx: &mut App) {
 97    cx.defer(|cx| {
 98        cx.global::<BannerGlobal>()
 99            .entity
100            .clone()
101            .update(cx, |this, cx| {
102                this.dismissed = false;
103                cx.notify();
104            });
105    });
106
107    let source = &cx.global::<BannerGlobal>().entity.read(cx).source;
108    let dismissed_at = dismissed_at_key(source);
109    let kvp = db::kvp::KeyValueStore::global(cx);
110    cx.spawn(async move |_| kvp.delete_kvp(dismissed_at).await)
111        .detach_and_log_err(cx);
112}
113
114impl Render for OnboardingBanner {
115    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
116        if !self.should_show(cx) {
117            return div();
118        }
119
120        let border_color = cx.theme().colors().editor_foreground.opacity(0.3);
121        let banner = h_flex()
122            .rounded_sm()
123            .border_1()
124            .border_color(border_color)
125            .occlude()
126            .child(
127                ButtonLike::new("try-a-feature")
128                    .child(
129                        h_flex()
130                            .h_full()
131                            .gap_1()
132                            .child(Icon::new(self.details.icon_name).size(IconSize::XSmall))
133                            .child(
134                                h_flex()
135                                    .gap_0p5()
136                                    .when_some(self.details.subtitle.as_ref(), |this, subtitle| {
137                                        this.child(
138                                            Label::new(subtitle)
139                                                .size(LabelSize::Small)
140                                                .color(Color::Muted),
141                                        )
142                                    })
143                                    .child(Label::new(&self.details.label).size(LabelSize::Small)),
144                            ),
145                    )
146                    .on_click(cx.listener(|this, _, window, cx| {
147                        telemetry::event!("Banner Clicked", source = this.source);
148                        this.dismiss(cx);
149                        window.dispatch_action(this.details.action.boxed_clone(), cx)
150                    })),
151            )
152            .child(
153                div().border_l_1().border_color(border_color).child(
154                    IconButton::new("close", IconName::Close)
155                        .icon_size(IconSize::Indicator)
156                        .on_click(cx.listener(|this, _, _window, cx| {
157                            telemetry::event!("Banner Dismissed", source = this.source);
158                            this.dismiss(cx)
159                        }))
160                        .tooltip(|_window, cx| {
161                            Tooltip::with_meta(
162                                "Close Announcement Banner",
163                                None,
164                                "It won't show again for this feature",
165                                cx,
166                            )
167                        }),
168                ),
169            );
170
171        div().pr_2().child(banner)
172    }
173}