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, async move |this, cx| {
119            if answer.await.ok() == Some(0) {
120                this.update(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, async move |workspace, cx| {
148                        let markdown = markdown.await.log_err();
149                        let buffer = project.update(cx, |project, cx| {
150                            project.create_local_buffer("", markdown, cx)
151                        })?;
152                        let system_specs = system_specs.await;
153
154                        workspace.update_in(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, async move |this, cx| {
244            let answer = answer.await.ok();
245            if answer == Some(0) {
246                this.update(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(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(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, async move |_, _cx| {
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 |_, _| 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        .detach();
387    }
388
389    fn valid_email_address(&self) -> bool {
390        !self.in_invalid_state(InvalidStateFlags::EmailAddress)
391    }
392
393    fn valid_character_count(&self) -> bool {
394        !self.in_invalid_state(InvalidStateFlags::CharacterCount)
395    }
396
397    fn in_invalid_state(&self, flag: InvalidStateFlags) -> bool {
398        match self.submission_state {
399            Some(SubmissionState::CannotSubmit {
400                reason: CannotSubmitReason::InvalidState { ref flags },
401            }) => flags.contains(flag),
402            _ => false,
403        }
404    }
405
406    fn awaiting_submission(&self) -> bool {
407        matches!(
408            self.submission_state,
409            Some(SubmissionState::CannotSubmit {
410                reason: CannotSubmitReason::AwaitingSubmission
411            })
412        )
413    }
414
415    fn can_submit(&self) -> bool {
416        matches!(self.submission_state, Some(SubmissionState::CanSubmit))
417    }
418
419    fn cancel(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context<Self>) {
420        cx.emit(DismissEvent)
421    }
422}
423
424impl Render for FeedbackModal {
425    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
426        self.update_submission_state(cx);
427
428        let submit_button_text = if self.awaiting_submission() {
429            "Submitting..."
430        } else {
431            "Submit"
432        };
433
434        let open_zed_repo =
435            cx.listener(|_, _, window, cx| window.dispatch_action(Box::new(OpenZedRepo), cx));
436
437        v_flex()
438            .elevation_3(cx)
439            .key_context("GiveFeedback")
440            .on_action(cx.listener(Self::cancel))
441            .min_w(rems(40.))
442            .max_w(rems(96.))
443            .h(rems(32.))
444            .p_4()
445            .gap_2()
446            .child(Headline::new("Give Feedback"))
447            .child(
448                Label::new(if self.character_count < *FEEDBACK_CHAR_LIMIT.start() {
449                    format!(
450                        "Feedback must be at least {} characters.",
451                        FEEDBACK_CHAR_LIMIT.start()
452                    )
453                } else {
454                    format!(
455                        "Characters: {}",
456                        *FEEDBACK_CHAR_LIMIT.end() - self.character_count
457                    )
458                })
459                .color(if self.valid_character_count() {
460                    Color::Success
461                } else {
462                    Color::Error
463                }),
464            )
465            .child(
466                div()
467                    .flex_1()
468                    .bg(cx.theme().colors().editor_background)
469                    .p_2()
470                    .border_1()
471                    .rounded_sm()
472                    .border_color(cx.theme().colors().border)
473                    .child(self.feedback_editor.clone()),
474            )
475            .child(
476                v_flex()
477                    .gap_1()
478                    .child(
479                        h_flex()
480                            .bg(cx.theme().colors().editor_background)
481                            .p_2()
482                            .border_1()
483                            .rounded_sm()
484                            .border_color(if self.valid_email_address() {
485                                cx.theme().colors().border
486                            } else {
487                                cx.theme().status().error_border
488                            })
489                            .child(self.email_address_editor.clone()),
490                    )
491                    .child(
492                        Label::new("Provide an email address if you want us to be able to reply.")
493                            .size(LabelSize::Small)
494                            .color(Color::Muted),
495                    ),
496            )
497            .child(
498                h_flex()
499                    .justify_between()
500                    .gap_1()
501                    .child(
502                        Button::new("zed_repository", "Zed Repository")
503                            .style(ButtonStyle::Transparent)
504                            .icon(IconName::ExternalLink)
505                            .icon_position(IconPosition::End)
506                            .icon_size(IconSize::Small)
507                            .on_click(open_zed_repo),
508                    )
509                    .child(
510                        h_flex()
511                            .gap_1()
512                            .child(
513                                Button::new("cancel_feedback", "Cancel")
514                                    .style(ButtonStyle::Subtle)
515                                    .color(Color::Muted)
516                                    .on_click(cx.listener(move |_, _, window, cx| {
517                                        cx.spawn_in(window, async move |this, cx| {
518                                            this.update(cx, |_, cx| cx.emit(DismissEvent)).ok();
519                                        })
520                                        .detach();
521                                    })),
522                            )
523                            .child(
524                                Button::new("submit_feedback", submit_button_text)
525                                    .color(Color::Accent)
526                                    .style(ButtonStyle::Filled)
527                                    .on_click(cx.listener(|this, _, window, cx| {
528                                        this.submit(window, cx).detach();
529                                    }))
530                                    .tooltip(move |_, cx| {
531                                        Tooltip::simple("Submit feedback to the Zed team.", cx)
532                                    })
533                                    .when(!self.can_submit(), |this| this.disabled(true)),
534                            ),
535                    ),
536            )
537    }
538}
539
540// TODO: Testing of various button states, dismissal prompts, etc. :)