onboarding_banner.rs

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