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