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