1use gpui::{Action, Entity, Global, Render};
2use ui::{prelude::*, ButtonLike, Tooltip};
3use util::ResultExt;
4
5/// Prompts the user to try Zed's features
6pub struct Banner {
7 dismissed: bool,
8 source: String,
9 details: BannerDetails,
10}
11
12#[derive(Clone)]
13struct BannerGlobal {
14 entity: Entity<Banner>,
15}
16impl Global for BannerGlobal {}
17
18pub struct BannerDetails {
19 pub action: Box<dyn Action>,
20 pub banner_label: Box<dyn Fn(&Window, &mut Context<Banner>) -> AnyElement>,
21}
22
23impl Banner {
24 pub fn new(source: &str, details: BannerDetails, cx: &mut Context<Self>) -> Self {
25 cx.set_global(BannerGlobal {
26 entity: cx.entity(),
27 });
28 Self {
29 source: source.to_string(),
30 details,
31 dismissed: get_dismissed(source),
32 }
33 }
34
35 fn should_show(&self, _cx: &mut App) -> bool {
36 !self.dismissed
37 }
38
39 fn dismiss(&mut self, cx: &mut Context<Self>) {
40 telemetry::event!("Banner Dismissed", source = self.source);
41 persist_dismissed(&self.source, cx);
42 self.dismissed = true;
43 cx.notify();
44 }
45}
46
47fn dismissed_at_key(source: &str) -> String {
48 format!(
49 "{}_{}",
50 "_banner_dismissed_at",
51 source.to_lowercase().trim().replace(" ", "_")
52 )
53}
54
55fn get_dismissed(source: &str) -> bool {
56 let dismissed_at = if source == "Git Onboarding" {
57 "zed_git_banner_dismissed_at".to_string()
58 } else {
59 dismissed_at_key(source)
60 };
61 db::kvp::KEY_VALUE_STORE
62 .read_kvp(&dismissed_at)
63 .log_err()
64 .map_or(false, |dismissed| dismissed.is_some())
65}
66
67fn persist_dismissed(source: &str, cx: &mut App) {
68 let dismissed_at = dismissed_at_key(source);
69 cx.spawn(async |_| {
70 let time = chrono::Utc::now().to_rfc3339();
71 db::kvp::KEY_VALUE_STORE.write_kvp(dismissed_at, time).await
72 })
73 .detach_and_log_err(cx);
74}
75
76pub fn restore_banner(cx: &mut App) {
77 cx.defer(|cx| {
78 cx.global::<BannerGlobal>()
79 .entity
80 .clone()
81 .update(cx, |this, cx| {
82 this.dismissed = false;
83 cx.notify();
84 });
85 });
86
87 let source = &cx.global::<BannerGlobal>().entity.read(cx).source;
88 let dismissed_at = dismissed_at_key(source);
89 cx.spawn(async |_| db::kvp::KEY_VALUE_STORE.delete_kvp(dismissed_at).await)
90 .detach_and_log_err(cx);
91}
92
93impl Render for Banner {
94 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
95 if !self.should_show(cx) {
96 return div();
97 }
98
99 let border_color = cx.theme().colors().editor_foreground.opacity(0.3);
100 let banner = h_flex()
101 .rounded_sm()
102 .border_1()
103 .border_color(border_color)
104 .child(
105 ButtonLike::new("try-a-feature")
106 .child((self.details.banner_label)(window, cx))
107 .on_click(cx.listener(|this, _, window, cx| {
108 telemetry::event!("Banner Clicked", source = this.source);
109 this.dismiss(cx);
110 window.dispatch_action(this.details.action.boxed_clone(), cx)
111 })),
112 )
113 .child(
114 div().border_l_1().border_color(border_color).child(
115 IconButton::new("close", IconName::Close)
116 .icon_size(IconSize::Indicator)
117 .on_click(cx.listener(|this, _, _window, cx| this.dismiss(cx)))
118 .tooltip(|window, cx| {
119 Tooltip::with_meta(
120 "Close Announcement Banner",
121 None,
122 "It won't show again for this feature",
123 window,
124 cx,
125 )
126 }),
127 ),
128 );
129
130 div().pr_2().child(banner)
131 }
132}