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}