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