1// This module provides infrastructure for showing onboarding banners in the title bar.
2// It's currently not in use but is kept for future feature announcements.
3#![allow(dead_code)]
4
5use gpui::{Action, Entity, Global, Render, SharedString};
6use ui::{ButtonLike, Tooltip, prelude::*};
7use util::ResultExt;
8
9/// Prompts the user to try newly released Zed's features
10pub struct OnboardingBanner {
11 dismissed: bool,
12 source: String,
13 details: BannerDetails,
14 visible_when: Option<Box<dyn Fn(&mut App) -> bool>>,
15}
16
17#[derive(Clone)]
18struct BannerGlobal {
19 entity: Entity<OnboardingBanner>,
20}
21impl Global for BannerGlobal {}
22
23pub struct BannerDetails {
24 pub action: Box<dyn Action>,
25 pub icon_name: IconName,
26 pub label: SharedString,
27 pub subtitle: Option<SharedString>,
28}
29
30impl OnboardingBanner {
31 pub fn new(
32 source: &str,
33 icon_name: IconName,
34 label: impl Into<SharedString>,
35 subtitle: Option<SharedString>,
36 action: Box<dyn Action>,
37 cx: &mut Context<Self>,
38 ) -> Self {
39 cx.set_global(BannerGlobal {
40 entity: cx.entity(),
41 });
42 Self {
43 source: source.to_string(),
44 details: BannerDetails {
45 action,
46 icon_name,
47 label: label.into(),
48 subtitle: subtitle.or(Some(SharedString::from("Introducing:"))),
49 },
50 visible_when: None,
51 dismissed: get_dismissed(source, cx),
52 }
53 }
54
55 pub fn visible_when(mut self, predicate: impl Fn(&mut App) -> bool + 'static) -> Self {
56 self.visible_when = Some(Box::new(predicate));
57 self
58 }
59
60 fn should_show(&self, cx: &mut App) -> bool {
61 !self.dismissed && self.visible_when.as_ref().map_or(true, |f| f(cx))
62 }
63
64 fn dismiss(&mut self, cx: &mut Context<Self>) {
65 persist_dismissed(&self.source, cx);
66 self.dismissed = true;
67 cx.notify();
68 }
69}
70
71fn dismissed_at_key(source: &str) -> String {
72 if source == "Git Onboarding" {
73 "zed_git_banner_dismissed_at".to_string()
74 } else {
75 format!(
76 "{}_banner_dismissed_at",
77 source.to_lowercase().trim().replace(" ", "_")
78 )
79 }
80}
81
82fn get_dismissed(source: &str, cx: &App) -> bool {
83 let dismissed_at = dismissed_at_key(source);
84 db::kvp::KeyValueStore::global(cx)
85 .read_kvp(&dismissed_at)
86 .log_err()
87 .is_some_and(|dismissed| dismissed.is_some())
88}
89
90fn persist_dismissed(source: &str, cx: &mut App) {
91 let dismissed_at = dismissed_at_key(source);
92 let kvp = db::kvp::KeyValueStore::global(cx);
93 cx.spawn(async move |_| {
94 let time = chrono::Utc::now().to_rfc3339();
95 kvp.write_kvp(dismissed_at, time).await
96 })
97 .detach_and_log_err(cx);
98}
99
100pub fn restore_banner(cx: &mut App) {
101 if let Some(banner_global) = cx.try_global::<BannerGlobal>() {
102 let entity = banner_global.entity.clone();
103 cx.defer(move |cx| {
104 entity.update(cx, |this, cx| {
105 this.dismissed = false;
106 cx.notify();
107 });
108 });
109
110 let source = &cx.global::<BannerGlobal>().entity.read(cx).source;
111 let dismissed_at = dismissed_at_key(source);
112 let kvp = db::kvp::KeyValueStore::global(cx);
113 cx.spawn(async move |_| kvp.delete_kvp(dismissed_at).await)
114 .detach_and_log_err(cx);
115 }
116}
117
118impl Render for OnboardingBanner {
119 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
120 if !self.should_show(cx) {
121 return div();
122 }
123
124 let border_color = cx.theme().colors().editor_foreground.opacity(0.3);
125 let banner = h_flex()
126 .rounded_sm()
127 .border_1()
128 .border_color(border_color)
129 .occlude()
130 .child(
131 ButtonLike::new("try-a-feature")
132 .child(
133 h_flex()
134 .h_full()
135 .gap_1()
136 .child(Icon::new(self.details.icon_name).size(IconSize::XSmall))
137 .child(
138 h_flex()
139 .gap_0p5()
140 .when_some(self.details.subtitle.as_ref(), |this, subtitle| {
141 this.child(
142 Label::new(subtitle)
143 .size(LabelSize::Small)
144 .color(Color::Muted),
145 )
146 })
147 .child(Label::new(&self.details.label).size(LabelSize::Small)),
148 ),
149 )
150 .on_click(cx.listener(|this, _, window, cx| {
151 telemetry::event!("Banner Clicked", source = this.source);
152 this.dismiss(cx);
153 window.dispatch_action(this.details.action.boxed_clone(), cx)
154 })),
155 )
156 .child(
157 div().border_l_1().border_color(border_color).child(
158 IconButton::new("close", IconName::Close)
159 .icon_size(IconSize::Indicator)
160 .on_click(cx.listener(|this, _, _window, cx| {
161 telemetry::event!("Banner Dismissed", source = this.source);
162 this.dismiss(cx)
163 }))
164 .tooltip(|_window, cx| {
165 Tooltip::with_meta(
166 "Close Announcement Banner",
167 None,
168 "It won't show again for this feature",
169 cx,
170 )
171 }),
172 ),
173 );
174
175 div().pr_2().child(banner)
176 }
177}