1use std::{
2 ops::RangeInclusive,
3 sync::{Arc, LazyLock},
4 time::Duration,
5};
6
7use anyhow::{anyhow, bail};
8use bitflags::bitflags;
9use client::Client;
10use db::kvp::KEY_VALUE_STORE;
11use editor::{Editor, EditorEvent};
12use futures::AsyncReadExt;
13use gpui::{
14 div, rems, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
15 PromptLevel, Render, Task, Window,
16};
17use http_client::HttpClient;
18use language::Buffer;
19use project::Project;
20use regex::Regex;
21use serde_derive::Serialize;
22use ui::{prelude::*, Button, ButtonStyle, IconPosition, Tooltip};
23use util::ResultExt;
24use workspace::{DismissDecision, ModalView, Workspace};
25use zed_actions::feedback::GiveFeedback;
26
27use crate::{system_specs::SystemSpecs, OpenZedRepo};
28
29// For UI testing purposes
30const SEND_SUCCESS_IN_DEV_MODE: bool = true;
31const SEND_TIME_IN_DEV_MODE: Duration = Duration::from_secs(2);
32
33// Temporary, until tests are in place
34#[cfg(debug_assertions)]
35const DEV_MODE: bool = true;
36
37#[cfg(not(debug_assertions))]
38const DEV_MODE: bool = false;
39
40const DATABASE_KEY_NAME: &str = "email_address";
41static EMAIL_REGEX: LazyLock<Regex> =
42 LazyLock::new(|| Regex::new(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b").unwrap());
43const FEEDBACK_CHAR_LIMIT: RangeInclusive<i32> = 10..=5000;
44const FEEDBACK_SUBMISSION_ERROR_TEXT: &str =
45 "Feedback failed to submit, see error log for details.";
46
47#[derive(Serialize)]
48struct FeedbackRequestBody<'a> {
49 feedback_text: &'a str,
50 email: Option<String>,
51 installation_id: Option<Arc<str>>,
52 metrics_id: Option<Arc<str>>,
53 system_specs: SystemSpecs,
54 is_staff: bool,
55}
56
57bitflags! {
58 #[derive(Debug, Clone, PartialEq)]
59 struct InvalidStateFlags: u8 {
60 const EmailAddress = 0b00000001;
61 const CharacterCount = 0b00000010;
62 }
63}
64
65#[derive(Debug, Clone, PartialEq)]
66enum CannotSubmitReason {
67 InvalidState { flags: InvalidStateFlags },
68 AwaitingSubmission,
69}
70
71#[derive(Debug, Clone, PartialEq)]
72enum SubmissionState {
73 CanSubmit,
74 CannotSubmit { reason: CannotSubmitReason },
75}
76
77pub struct FeedbackModal {
78 system_specs: SystemSpecs,
79 feedback_editor: Entity<Editor>,
80 email_address_editor: Entity<Editor>,
81 submission_state: Option<SubmissionState>,
82 dismiss_modal: bool,
83 character_count: i32,
84}
85
86impl Focusable for FeedbackModal {
87 fn focus_handle(&self, cx: &App) -> FocusHandle {
88 self.feedback_editor.focus_handle(cx)
89 }
90}
91impl EventEmitter<DismissEvent> for FeedbackModal {}
92
93impl ModalView for FeedbackModal {
94 fn on_before_dismiss(
95 &mut self,
96 window: &mut Window,
97 cx: &mut Context<Self>,
98 ) -> DismissDecision {
99 self.update_email_in_store(window, cx);
100
101 if self.dismiss_modal {
102 return DismissDecision::Dismiss(true);
103 }
104
105 let has_feedback = self.feedback_editor.read(cx).text_option(cx).is_some();
106 if !has_feedback {
107 return DismissDecision::Dismiss(true);
108 }
109
110 let answer = window.prompt(
111 PromptLevel::Info,
112 "Discard feedback?",
113 None,
114 &["Yes", "No"],
115 cx,
116 );
117
118 cx.spawn_in(window, move |this, mut cx| async move {
119 if answer.await.ok() == Some(0) {
120 this.update(&mut cx, |this, cx| {
121 this.dismiss_modal = true;
122 cx.emit(DismissEvent)
123 })
124 .log_err();
125 }
126 })
127 .detach();
128
129 DismissDecision::Pending
130 }
131}
132
133impl FeedbackModal {
134 pub fn register(workspace: &mut Workspace, _: &mut Window, cx: &mut Context<Workspace>) {
135 let _handle = cx.entity().downgrade();
136 workspace.register_action(move |workspace, _: &GiveFeedback, window, cx| {
137 workspace
138 .with_local_workspace(window, cx, |workspace, window, cx| {
139 let markdown = workspace
140 .app_state()
141 .languages
142 .language_for_name("Markdown");
143
144 let project = workspace.project().clone();
145
146 let system_specs = SystemSpecs::new(window, cx);
147 cx.spawn_in(window, |workspace, mut cx| async move {
148 let markdown = markdown.await.log_err();
149 let buffer = project.update(&mut cx, |project, cx| {
150 project.create_local_buffer("", markdown, cx)
151 })?;
152 let system_specs = system_specs.await;
153
154 workspace.update_in(&mut cx, |workspace, window, cx| {
155 workspace.toggle_modal(window, cx, move |window, cx| {
156 FeedbackModal::new(system_specs, project, buffer, window, cx)
157 });
158 })?;
159
160 anyhow::Ok(())
161 })
162 .detach_and_log_err(cx);
163 })
164 .detach_and_log_err(cx);
165 });
166 }
167
168 pub fn new(
169 system_specs: SystemSpecs,
170 project: Entity<Project>,
171 buffer: Entity<Buffer>,
172 window: &mut Window,
173 cx: &mut Context<Self>,
174 ) -> Self {
175 let email_address_editor = cx.new(|cx| {
176 let mut editor = Editor::single_line(window, cx);
177 editor.set_placeholder_text("Email address (optional)", cx);
178
179 if let Ok(Some(email_address)) = KEY_VALUE_STORE.read_kvp(DATABASE_KEY_NAME) {
180 editor.set_text(email_address, window, cx)
181 }
182
183 editor
184 });
185
186 let feedback_editor = cx.new(|cx| {
187 let mut editor = Editor::for_buffer(buffer, Some(project.clone()), window, cx);
188 editor.set_placeholder_text(
189 "You can use markdown to organize your feedback with code and links.",
190 cx,
191 );
192 editor.set_show_gutter(false, cx);
193 editor.set_show_indent_guides(false, cx);
194 editor.set_show_inline_completions(Some(false), window, cx);
195 editor.set_vertical_scroll_margin(5, cx);
196 editor.set_use_modal_editing(false);
197 editor
198 });
199
200 cx.subscribe(&feedback_editor, |this, editor, event: &EditorEvent, cx| {
201 if matches!(event, EditorEvent::Edited { .. }) {
202 this.character_count = editor
203 .read(cx)
204 .buffer()
205 .read(cx)
206 .as_singleton()
207 .expect("Feedback editor is never a multi-buffer")
208 .read(cx)
209 .len() as i32;
210 cx.notify();
211 }
212 })
213 .detach();
214
215 Self {
216 system_specs: system_specs.clone(),
217 feedback_editor,
218 email_address_editor,
219 submission_state: None,
220 dismiss_modal: false,
221 character_count: 0,
222 }
223 }
224
225 pub fn submit(
226 &mut self,
227 window: &mut Window,
228 cx: &mut Context<Self>,
229 ) -> Task<anyhow::Result<()>> {
230 let feedback_text = self.feedback_editor.read(cx).text(cx).trim().to_string();
231 let email = self.email_address_editor.read(cx).text_option(cx);
232
233 let answer = window.prompt(
234 PromptLevel::Info,
235 "Ready to submit your feedback?",
236 None,
237 &["Yes, Submit!", "No"],
238 cx,
239 );
240 let client = Client::global(cx).clone();
241 let specs = self.system_specs.clone();
242 cx.spawn_in(window, |this, mut cx| async move {
243 let answer = answer.await.ok();
244 if answer == Some(0) {
245 this.update(&mut cx, |this, cx| {
246 this.submission_state = Some(SubmissionState::CannotSubmit {
247 reason: CannotSubmitReason::AwaitingSubmission,
248 });
249 cx.notify();
250 })
251 .log_err();
252
253 let res =
254 FeedbackModal::submit_feedback(&feedback_text, email, client, specs).await;
255
256 match res {
257 Ok(_) => {
258 this.update(&mut cx, |this, cx| {
259 this.dismiss_modal = true;
260 cx.notify();
261 cx.emit(DismissEvent)
262 })
263 .ok();
264 }
265 Err(error) => {
266 log::error!("{}", error);
267 this.update_in(&mut cx, |this, window, cx| {
268 let prompt = window.prompt(
269 PromptLevel::Critical,
270 FEEDBACK_SUBMISSION_ERROR_TEXT,
271 None,
272 &["OK"],
273 cx,
274 );
275 cx.spawn_in(window, |_, _cx| async move {
276 prompt.await.ok();
277 })
278 .detach();
279
280 this.submission_state = Some(SubmissionState::CanSubmit);
281 cx.notify();
282 })
283 .log_err();
284 }
285 }
286 }
287 })
288 .detach();
289
290 Task::ready(Ok(()))
291 }
292
293 async fn submit_feedback(
294 feedback_text: &str,
295 email: Option<String>,
296 zed_client: Arc<Client>,
297 system_specs: SystemSpecs,
298 ) -> anyhow::Result<()> {
299 if DEV_MODE {
300 smol::Timer::after(SEND_TIME_IN_DEV_MODE).await;
301
302 if SEND_SUCCESS_IN_DEV_MODE {
303 return Ok(());
304 } else {
305 return Err(anyhow!("Error submitting feedback"));
306 }
307 }
308
309 let telemetry = zed_client.telemetry();
310 let installation_id = telemetry.installation_id();
311 let metrics_id = telemetry.metrics_id();
312 let is_staff = telemetry.is_staff();
313 let http_client = zed_client.http_client();
314 let feedback_endpoint = http_client.build_url("/api/feedback");
315 let request = FeedbackRequestBody {
316 feedback_text,
317 email,
318 installation_id,
319 metrics_id,
320 system_specs,
321 is_staff: is_staff.unwrap_or(false),
322 };
323 let json_bytes = serde_json::to_vec(&request)?;
324 let request = http_client::http::Request::post(feedback_endpoint)
325 .header("content-type", "application/json")
326 .body(json_bytes.into())?;
327 let mut response = http_client.send(request).await?;
328 let mut body = String::new();
329 response.body_mut().read_to_string(&mut body).await?;
330 let response_status = response.status();
331 if !response_status.is_success() {
332 bail!("Feedback API failed with error: {}", response_status)
333 }
334 Ok(())
335 }
336
337 fn update_submission_state(&mut self, cx: &mut Context<Self>) {
338 if self.awaiting_submission() {
339 return;
340 }
341
342 let mut invalid_state_flags = InvalidStateFlags::empty();
343
344 let valid_email_address = match self.email_address_editor.read(cx).text_option(cx) {
345 Some(email_address) => EMAIL_REGEX.is_match(&email_address),
346 None => true,
347 };
348
349 if !valid_email_address {
350 invalid_state_flags |= InvalidStateFlags::EmailAddress;
351 }
352
353 if !FEEDBACK_CHAR_LIMIT.contains(&self.character_count) {
354 invalid_state_flags |= InvalidStateFlags::CharacterCount;
355 }
356
357 if invalid_state_flags.is_empty() {
358 self.submission_state = Some(SubmissionState::CanSubmit);
359 } else {
360 self.submission_state = Some(SubmissionState::CannotSubmit {
361 reason: CannotSubmitReason::InvalidState {
362 flags: invalid_state_flags,
363 },
364 });
365 }
366 }
367
368 fn update_email_in_store(&self, window: &mut Window, cx: &mut Context<Self>) {
369 let email = self.email_address_editor.read(cx).text_option(cx);
370
371 cx.spawn_in(window, |_, _| async move {
372 match email {
373 Some(email) => {
374 KEY_VALUE_STORE
375 .write_kvp(DATABASE_KEY_NAME.to_string(), email)
376 .await
377 .ok();
378 }
379 None => {
380 KEY_VALUE_STORE
381 .delete_kvp(DATABASE_KEY_NAME.to_string())
382 .await
383 .ok();
384 }
385 }
386 })
387 .detach();
388 }
389
390 fn valid_email_address(&self) -> bool {
391 !self.in_invalid_state(InvalidStateFlags::EmailAddress)
392 }
393
394 fn valid_character_count(&self) -> bool {
395 !self.in_invalid_state(InvalidStateFlags::CharacterCount)
396 }
397
398 fn in_invalid_state(&self, flag: InvalidStateFlags) -> bool {
399 match self.submission_state {
400 Some(SubmissionState::CannotSubmit {
401 reason: CannotSubmitReason::InvalidState { ref flags },
402 }) => flags.contains(flag),
403 _ => false,
404 }
405 }
406
407 fn awaiting_submission(&self) -> bool {
408 matches!(
409 self.submission_state,
410 Some(SubmissionState::CannotSubmit {
411 reason: CannotSubmitReason::AwaitingSubmission
412 })
413 )
414 }
415
416 fn can_submit(&self) -> bool {
417 matches!(self.submission_state, Some(SubmissionState::CanSubmit))
418 }
419
420 fn cancel(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context<Self>) {
421 cx.emit(DismissEvent)
422 }
423}
424
425impl Render for FeedbackModal {
426 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
427 self.update_submission_state(cx);
428
429 let submit_button_text = if self.awaiting_submission() {
430 "Submitting..."
431 } else {
432 "Submit"
433 };
434
435 let open_zed_repo =
436 cx.listener(|_, _, window, cx| window.dispatch_action(Box::new(OpenZedRepo), cx));
437
438 v_flex()
439 .elevation_3(cx)
440 .key_context("GiveFeedback")
441 .on_action(cx.listener(Self::cancel))
442 .min_w(rems(40.))
443 .max_w(rems(96.))
444 .h(rems(32.))
445 .p_4()
446 .gap_2()
447 .child(Headline::new("Give Feedback"))
448 .child(
449 Label::new(if self.character_count < *FEEDBACK_CHAR_LIMIT.start() {
450 format!(
451 "Feedback must be at least {} characters.",
452 FEEDBACK_CHAR_LIMIT.start()
453 )
454 } else {
455 format!(
456 "Characters: {}",
457 *FEEDBACK_CHAR_LIMIT.end() - self.character_count
458 )
459 })
460 .color(if self.valid_character_count() {
461 Color::Success
462 } else {
463 Color::Error
464 }),
465 )
466 .child(
467 div()
468 .flex_1()
469 .bg(cx.theme().colors().editor_background)
470 .p_2()
471 .border_1()
472 .rounded_md()
473 .border_color(cx.theme().colors().border)
474 .child(self.feedback_editor.clone()),
475 )
476 .child(
477 v_flex()
478 .gap_1()
479 .child(
480 h_flex()
481 .bg(cx.theme().colors().editor_background)
482 .p_2()
483 .border_1()
484 .rounded_md()
485 .border_color(if self.valid_email_address() {
486 cx.theme().colors().border
487 } else {
488 cx.theme().status().error_border
489 })
490 .child(self.email_address_editor.clone()),
491 )
492 .child(
493 Label::new("Provide an email address if you want us to be able to reply.")
494 .size(LabelSize::Small)
495 .color(Color::Muted),
496 ),
497 )
498 .child(
499 h_flex()
500 .justify_between()
501 .gap_1()
502 .child(
503 Button::new("zed_repository", "Zed Repository")
504 .style(ButtonStyle::Transparent)
505 .icon(IconName::ExternalLink)
506 .icon_position(IconPosition::End)
507 .icon_size(IconSize::Small)
508 .on_click(open_zed_repo),
509 )
510 .child(
511 h_flex()
512 .gap_1()
513 .child(
514 Button::new("cancel_feedback", "Cancel")
515 .style(ButtonStyle::Subtle)
516 .color(Color::Muted)
517 .on_click(cx.listener(move |_, _, window, cx| {
518 cx.spawn_in(window, |this, mut cx| async move {
519 this.update(&mut cx, |_, cx| cx.emit(DismissEvent))
520 .ok();
521 })
522 .detach();
523 })),
524 )
525 .child(
526 Button::new("submit_feedback", submit_button_text)
527 .color(Color::Accent)
528 .style(ButtonStyle::Filled)
529 .on_click(cx.listener(|this, _, window, cx| {
530 this.submit(window, cx).detach();
531 }))
532 .tooltip(move |_, cx| {
533 Tooltip::simple("Submit feedback to the Zed team.", cx)
534 })
535 .when(!self.can_submit(), |this| this.disabled(true)),
536 ),
537 ),
538 )
539 }
540}
541
542// TODO: Testing of various button states, dismissal prompts, etc. :)