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 .occlude()
124 .child(
125 ButtonLike::new("try-a-feature")
126 .child(
127 h_flex()
128 .h_full()
129 .gap_1()
130 .child(Icon::new(self.details.icon_name).size(IconSize::XSmall))
131 .child(
132 h_flex()
133 .gap_0p5()
134 .when_some(self.details.subtitle.as_ref(), |this, subtitle| {
135 this.child(
136 Label::new(subtitle)
137 .size(LabelSize::Small)
138 .color(Color::Muted),
139 )
140 })
141 .child(Label::new(&self.details.label).size(LabelSize::Small)),
142 ),
143 )
144 .on_click(cx.listener(|this, _, window, cx| {
145 telemetry::event!("Banner Clicked", source = this.source);
146 this.dismiss(cx);
147 window.dispatch_action(this.details.action.boxed_clone(), cx)
148 })),
149 )
150 .child(
151 div().border_l_1().border_color(border_color).child(
152 IconButton::new("close", IconName::Close)
153 .icon_size(IconSize::Indicator)
154 .on_click(cx.listener(|this, _, _window, cx| {
155 telemetry::event!("Banner Dismissed", source = this.source);
156 this.dismiss(cx)
157 }))
158 .tooltip(|_window, cx| {
159 Tooltip::with_meta(
160 "Close Announcement Banner",
161 None,
162 "It won't show again for this feature",
163 cx,
164 )
165 }),
166 ),
167 );
168
169 div().pr_2().child(banner)
170 }
171}