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}
 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        persist_dismissed(&self.source, cx);
 55        self.dismissed = true;
 56        cx.notify();
 57    }
 58}
 59
 60fn dismissed_at_key(source: &str) -> String {
 61    if source == "Git Onboarding" {
 62        "zed_git_banner_dismissed_at".to_string()
 63    } else {
 64        format!(
 65            "{}_banner_dismissed_at",
 66            source.to_lowercase().trim().replace(" ", "_")
 67        )
 68    }
 69}
 70
 71fn get_dismissed(source: &str) -> bool {
 72    let dismissed_at = dismissed_at_key(source);
 73    db::kvp::KEY_VALUE_STORE
 74        .read_kvp(&dismissed_at)
 75        .log_err()
 76        .is_some_and(|dismissed| dismissed.is_some())
 77}
 78
 79fn persist_dismissed(source: &str, cx: &mut App) {
 80    let dismissed_at = dismissed_at_key(source);
 81    cx.spawn(async |_| {
 82        let time = chrono::Utc::now().to_rfc3339();
 83        db::kvp::KEY_VALUE_STORE.write_kvp(dismissed_at, time).await
 84    })
 85    .detach_and_log_err(cx);
 86}
 87
 88pub fn restore_banner(cx: &mut App) {
 89    cx.defer(|cx| {
 90        cx.global::<BannerGlobal>()
 91            .entity
 92            .clone()
 93            .update(cx, |this, cx| {
 94                this.dismissed = false;
 95                cx.notify();
 96            });
 97    });
 98
 99    let source = &cx.global::<BannerGlobal>().entity.read(cx).source;
100    let dismissed_at = dismissed_at_key(source);
101    cx.spawn(async |_| db::kvp::KEY_VALUE_STORE.delete_kvp(dismissed_at).await)
102        .detach_and_log_err(cx);
103}
104
105impl Render for OnboardingBanner {
106    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
107        if !self.should_show(cx) {
108            return div();
109        }
110
111        let border_color = cx.theme().colors().editor_foreground.opacity(0.3);
112        let banner = h_flex()
113            .rounded_sm()
114            .border_1()
115            .border_color(border_color)
116            .child(
117                ButtonLike::new("try-a-feature")
118                    .child(
119                        h_flex()
120                            .h_full()
121                            .gap_1()
122                            .child(Icon::new(self.details.icon_name).size(IconSize::XSmall))
123                            .child(
124                                h_flex()
125                                    .gap_0p5()
126                                    .when_some(self.details.subtitle.as_ref(), |this, subtitle| {
127                                        this.child(
128                                            Label::new(subtitle)
129                                                .size(LabelSize::Small)
130                                                .color(Color::Muted),
131                                        )
132                                    })
133                                    .child(Label::new(&self.details.label).size(LabelSize::Small)),
134                            ),
135                    )
136                    .on_click(cx.listener(|this, _, window, cx| {
137                        telemetry::event!("Banner Clicked", source = this.source);
138                        this.dismiss(cx);
139                        window.dispatch_action(this.details.action.boxed_clone(), cx)
140                    })),
141            )
142            .child(
143                div().border_l_1().border_color(border_color).child(
144                    IconButton::new("close", IconName::Close)
145                        .icon_size(IconSize::Indicator)
146                        .on_click(cx.listener(|this, _, _window, cx| {
147                            telemetry::event!("Banner Dismissed", source = this.source);
148                            this.dismiss(cx)
149                        }))
150                        .tooltip(|window, cx| {
151                            Tooltip::with_meta(
152                                "Close Announcement Banner",
153                                None,
154                                "It won't show again for this feature",
155                                window,
156                                cx,
157                            )
158                        }),
159                ),
160            );
161
162        div().pr_2().child(banner)
163    }
164}