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),
 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) -> bool {
 79    let dismissed_at = dismissed_at_key(source);
 80    db::kvp::KEY_VALUE_STORE
 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    cx.spawn(async |_| {
 89        let time = chrono::Utc::now().to_rfc3339();
 90        db::kvp::KEY_VALUE_STORE.write_kvp(dismissed_at, time).await
 91    })
 92    .detach_and_log_err(cx);
 93}
 94
 95pub fn restore_banner(cx: &mut App) {
 96    cx.defer(|cx| {
 97        cx.global::<BannerGlobal>()
 98            .entity
 99            .clone()
100            .update(cx, |this, cx| {
101                this.dismissed = false;
102                cx.notify();
103            });
104    });
105
106    let source = &cx.global::<BannerGlobal>().entity.read(cx).source;
107    let dismissed_at = dismissed_at_key(source);
108    cx.spawn(async |_| db::kvp::KEY_VALUE_STORE.delete_kvp(dismissed_at).await)
109        .detach_and_log_err(cx);
110}
111
112impl Render for OnboardingBanner {
113    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
114        if !self.should_show(cx) {
115            return div();
116        }
117
118        let border_color = cx.theme().colors().editor_foreground.opacity(0.3);
119        let banner = h_flex()
120            .rounded_sm()
121            .border_1()
122            .border_color(border_color)
123            .child(
124                ButtonLike::new("try-a-feature")
125                    .child(
126                        h_flex()
127                            .h_full()
128                            .gap_1()
129                            .child(Icon::new(self.details.icon_name).size(IconSize::XSmall))
130                            .child(
131                                h_flex()
132                                    .gap_0p5()
133                                    .when_some(self.details.subtitle.as_ref(), |this, subtitle| {
134                                        this.child(
135                                            Label::new(subtitle)
136                                                .size(LabelSize::Small)
137                                                .color(Color::Muted),
138                                        )
139                                    })
140                                    .child(Label::new(&self.details.label).size(LabelSize::Small)),
141                            ),
142                    )
143                    .on_click(cx.listener(|this, _, window, cx| {
144                        telemetry::event!("Banner Clicked", source = this.source);
145                        this.dismiss(cx);
146                        window.dispatch_action(this.details.action.boxed_clone(), cx)
147                    })),
148            )
149            .child(
150                div().border_l_1().border_color(border_color).child(
151                    IconButton::new("close", IconName::Close)
152                        .icon_size(IconSize::Indicator)
153                        .on_click(cx.listener(|this, _, _window, cx| {
154                            telemetry::event!("Banner Dismissed", source = this.source);
155                            this.dismiss(cx)
156                        }))
157                        .tooltip(|_window, cx| {
158                            Tooltip::with_meta(
159                                "Close Announcement Banner",
160                                None,
161                                "It won't show again for this feature",
162                                cx,
163                            )
164                        }),
165                ),
166            );
167
168        div().pr_2().child(banner)
169    }
170}