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