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_edit_predictions(Some(false), window, cx);
195 editor.set_vertical_scroll_margin(5, cx);
196 editor.set_use_modal_editing(false);
197 editor.set_soft_wrap();
198 editor
199 });
200
201 cx.subscribe(&feedback_editor, |this, editor, event: &EditorEvent, cx| {
202 if matches!(event, EditorEvent::Edited { .. }) {
203 this.character_count = editor
204 .read(cx)
205 .buffer()
206 .read(cx)
207 .as_singleton()
208 .expect("Feedback editor is never a multi-buffer")
209 .read(cx)
210 .len() as i32;
211 cx.notify();
212 }
213 })
214 .detach();
215
216 Self {
217 system_specs: system_specs.clone(),
218 feedback_editor,
219 email_address_editor,
220 submission_state: None,
221 dismiss_modal: false,
222 character_count: 0,
223 }
224 }
225
226 pub fn submit(
227 &mut self,
228 window: &mut Window,
229 cx: &mut Context<Self>,
230 ) -> Task<anyhow::Result<()>> {
231 let feedback_text = self.feedback_editor.read(cx).text(cx).trim().to_string();
232 let email = self.email_address_editor.read(cx).text_option(cx);
233
234 let answer = window.prompt(
235 PromptLevel::Info,
236 "Ready to submit your feedback?",
237 None,
238 &["Yes, Submit!", "No"],
239 cx,
240 );
241 let client = Client::global(cx).clone();
242 let specs = self.system_specs.clone();
243 cx.spawn_in(window, |this, mut cx| async move {
244 let answer = answer.await.ok();
245 if answer == Some(0) {
246 this.update(&mut cx, |this, cx| {
247 this.submission_state = Some(SubmissionState::CannotSubmit {
248 reason: CannotSubmitReason::AwaitingSubmission,
249 });
250 cx.notify();
251 })
252 .log_err();
253
254 let res =
255 FeedbackModal::submit_feedback(&feedback_text, email, client, specs).await;
256
257 match res {
258 Ok(_) => {
259 this.update(&mut cx, |this, cx| {
260 this.dismiss_modal = true;
261 cx.notify();
262 cx.emit(DismissEvent)
263 })
264 .ok();
265 }
266 Err(error) => {
267 log::error!("{}", error);
268 this.update_in(&mut cx, |this, window, cx| {
269 let prompt = window.prompt(
270 PromptLevel::Critical,
271 FEEDBACK_SUBMISSION_ERROR_TEXT,
272 None,
273 &["OK"],
274 cx,
275 );
276 cx.spawn_in(window, |_, _cx| async move {
277 prompt.await.ok();
278 })
279 .detach();
280
281 this.submission_state = Some(SubmissionState::CanSubmit);
282 cx.notify();
283 })
284 .log_err();
285 }
286 }
287 }
288 })
289 .detach();
290
291 Task::ready(Ok(()))
292 }
293
294 async fn submit_feedback(
295 feedback_text: &str,
296 email: Option<String>,
297 zed_client: Arc<Client>,
298 system_specs: SystemSpecs,
299 ) -> anyhow::Result<()> {
300 if DEV_MODE {
301 smol::Timer::after(SEND_TIME_IN_DEV_MODE).await;
302
303 if SEND_SUCCESS_IN_DEV_MODE {
304 return Ok(());
305 } else {
306 return Err(anyhow!("Error submitting feedback"));
307 }
308 }
309
310 let telemetry = zed_client.telemetry();
311 let installation_id = telemetry.installation_id();
312 let metrics_id = telemetry.metrics_id();
313 let is_staff = telemetry.is_staff();
314 let http_client = zed_client.http_client();
315 let feedback_endpoint = http_client.build_url("/api/feedback");
316 let request = FeedbackRequestBody {
317 feedback_text,
318 email,
319 installation_id,
320 metrics_id,
321 system_specs,
322 is_staff: is_staff.unwrap_or(false),
323 };
324 let json_bytes = serde_json::to_vec(&request)?;
325 let request = http_client::http::Request::post(feedback_endpoint)
326 .header("content-type", "application/json")
327 .body(json_bytes.into())?;
328 let mut response = http_client.send(request).await?;
329 let mut body = String::new();
330 response.body_mut().read_to_string(&mut body).await?;
331 let response_status = response.status();
332 if !response_status.is_success() {
333 bail!("Feedback API failed with error: {}", response_status)
334 }
335 Ok(())
336 }
337
338 fn update_submission_state(&mut self, cx: &mut Context<Self>) {
339 if self.awaiting_submission() {
340 return;
341 }
342
343 let mut invalid_state_flags = InvalidStateFlags::empty();
344
345 let valid_email_address = match self.email_address_editor.read(cx).text_option(cx) {
346 Some(email_address) => EMAIL_REGEX.is_match(&email_address),
347 None => true,
348 };
349
350 if !valid_email_address {
351 invalid_state_flags |= InvalidStateFlags::EmailAddress;
352 }
353
354 if !FEEDBACK_CHAR_LIMIT.contains(&self.character_count) {
355 invalid_state_flags |= InvalidStateFlags::CharacterCount;
356 }
357
358 if invalid_state_flags.is_empty() {
359 self.submission_state = Some(SubmissionState::CanSubmit);
360 } else {
361 self.submission_state = Some(SubmissionState::CannotSubmit {
362 reason: CannotSubmitReason::InvalidState {
363 flags: invalid_state_flags,
364 },
365 });
366 }
367 }
368
369 fn update_email_in_store(&self, window: &mut Window, cx: &mut Context<Self>) {
370 let email = self.email_address_editor.read(cx).text_option(cx);
371
372 cx.spawn_in(window, |_, _| async move {
373 match email {
374 Some(email) => {
375 KEY_VALUE_STORE
376 .write_kvp(DATABASE_KEY_NAME.to_string(), email)
377 .await
378 .ok();
379 }
380 None => {
381 KEY_VALUE_STORE
382 .delete_kvp(DATABASE_KEY_NAME.to_string())
383 .await
384 .ok();
385 }
386 }
387 })
388 .detach();
389 }
390
391 fn valid_email_address(&self) -> bool {
392 !self.in_invalid_state(InvalidStateFlags::EmailAddress)
393 }
394
395 fn valid_character_count(&self) -> bool {
396 !self.in_invalid_state(InvalidStateFlags::CharacterCount)
397 }
398
399 fn in_invalid_state(&self, flag: InvalidStateFlags) -> bool {
400 match self.submission_state {
401 Some(SubmissionState::CannotSubmit {
402 reason: CannotSubmitReason::InvalidState { ref flags },
403 }) => flags.contains(flag),
404 _ => false,
405 }
406 }
407
408 fn awaiting_submission(&self) -> bool {
409 matches!(
410 self.submission_state,
411 Some(SubmissionState::CannotSubmit {
412 reason: CannotSubmitReason::AwaitingSubmission
413 })
414 )
415 }
416
417 fn can_submit(&self) -> bool {
418 matches!(self.submission_state, Some(SubmissionState::CanSubmit))
419 }
420
421 fn cancel(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context<Self>) {
422 cx.emit(DismissEvent)
423 }
424}
425
426impl Render for FeedbackModal {
427 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
428 self.update_submission_state(cx);
429
430 let submit_button_text = if self.awaiting_submission() {
431 "Submitting..."
432 } else {
433 "Submit"
434 };
435
436 let open_zed_repo =
437 cx.listener(|_, _, window, cx| window.dispatch_action(Box::new(OpenZedRepo), cx));
438
439 v_flex()
440 .elevation_3(cx)
441 .key_context("GiveFeedback")
442 .on_action(cx.listener(Self::cancel))
443 .min_w(rems(40.))
444 .max_w(rems(96.))
445 .h(rems(32.))
446 .p_4()
447 .gap_2()
448 .child(Headline::new("Give Feedback"))
449 .child(
450 Label::new(if self.character_count < *FEEDBACK_CHAR_LIMIT.start() {
451 format!(
452 "Feedback must be at least {} characters.",
453 FEEDBACK_CHAR_LIMIT.start()
454 )
455 } else {
456 format!(
457 "Characters: {}",
458 *FEEDBACK_CHAR_LIMIT.end() - self.character_count
459 )
460 })
461 .color(if self.valid_character_count() {
462 Color::Success
463 } else {
464 Color::Error
465 }),
466 )
467 .child(
468 div()
469 .flex_1()
470 .bg(cx.theme().colors().editor_background)
471 .p_2()
472 .border_1()
473 .rounded_sm()
474 .border_color(cx.theme().colors().border)
475 .child(self.feedback_editor.clone()),
476 )
477 .child(
478 v_flex()
479 .gap_1()
480 .child(
481 h_flex()
482 .bg(cx.theme().colors().editor_background)
483 .p_2()
484 .border_1()
485 .rounded_sm()
486 .border_color(if self.valid_email_address() {
487 cx.theme().colors().border
488 } else {
489 cx.theme().status().error_border
490 })
491 .child(self.email_address_editor.clone()),
492 )
493 .child(
494 Label::new("Provide an email address if you want us to be able to reply.")
495 .size(LabelSize::Small)
496 .color(Color::Muted),
497 ),
498 )
499 .child(
500 h_flex()
501 .justify_between()
502 .gap_1()
503 .child(
504 Button::new("zed_repository", "Zed Repository")
505 .style(ButtonStyle::Transparent)
506 .icon(IconName::ExternalLink)
507 .icon_position(IconPosition::End)
508 .icon_size(IconSize::Small)
509 .on_click(open_zed_repo),
510 )
511 .child(
512 h_flex()
513 .gap_1()
514 .child(
515 Button::new("cancel_feedback", "Cancel")
516 .style(ButtonStyle::Subtle)
517 .color(Color::Muted)
518 .on_click(cx.listener(move |_, _, window, cx| {
519 cx.spawn_in(window, |this, mut cx| async move {
520 this.update(&mut cx, |_, cx| cx.emit(DismissEvent))
521 .ok();
522 })
523 .detach();
524 })),
525 )
526 .child(
527 Button::new("submit_feedback", submit_button_text)
528 .color(Color::Accent)
529 .style(ButtonStyle::Filled)
530 .on_click(cx.listener(|this, _, window, cx| {
531 this.submit(window, cx).detach();
532 }))
533 .tooltip(move |_, cx| {
534 Tooltip::simple("Submit feedback to the Zed team.", cx)
535 })
536 .when(!self.can_submit(), |this| this.disabled(true)),
537 ),
538 ),
539 )
540 }
541}
542
543// TODO: Testing of various button states, dismissal prompts, etc. :)