feedback_modal.rs

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