onboarding_banner.rs

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