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