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