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