feedback_modal.rs

  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. :)