feedback_modal.rs

  1use std::{ops::RangeInclusive, sync::Arc};
  2
  3use 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, Future};
  8use gpui::{
  9    div, rems, serde_json, AppContext, DismissEvent, Div, EventEmitter, FocusHandle, FocusableView,
 10    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
 23const DATABASE_KEY_NAME: &str = "email_address";
 24const EMAIL_REGEX: &str = r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b";
 25const FEEDBACK_CHAR_LIMIT: RangeInclusive<usize> = 10..=5000;
 26const FEEDBACK_SUBMISSION_ERROR_TEXT: &str =
 27    "Feedback failed to submit, see error log for details.";
 28
 29#[derive(Serialize)]
 30struct FeedbackRequestBody<'a> {
 31    feedback_text: &'a str,
 32    email: Option<String>,
 33    metrics_id: Option<Arc<str>>,
 34    installation_id: Option<Arc<str>>,
 35    system_specs: SystemSpecs,
 36    is_staff: bool,
 37    token: &'a str,
 38}
 39
 40pub struct FeedbackModal {
 41    system_specs: SystemSpecs,
 42    feedback_editor: View<Editor>,
 43    email_address_editor: View<Editor>,
 44    character_count: usize,
 45    pending_submission: bool,
 46}
 47
 48impl FocusableView for FeedbackModal {
 49    fn focus_handle(&self, cx: &AppContext) -> FocusHandle {
 50        self.feedback_editor.focus_handle(cx)
 51    }
 52}
 53impl EventEmitter<DismissEvent> for FeedbackModal {}
 54
 55impl ModalView for FeedbackModal {
 56    fn dismiss(&mut self, cx: &mut ViewContext<Self>) -> Task<bool> {
 57        let prompt = Self::prompt_dismiss(cx);
 58        cx.spawn(|_, _| prompt)
 59    }
 60}
 61
 62impl FeedbackModal {
 63    pub fn register(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) {
 64        let _handle = cx.view().downgrade();
 65        workspace.register_action(move |workspace, _: &GiveFeedback, cx| {
 66            let markdown = workspace
 67                .app_state()
 68                .languages
 69                .language_for_name("Markdown");
 70
 71            let project = workspace.project().clone();
 72
 73            cx.spawn(|workspace, mut cx| async move {
 74                let markdown = markdown.await.log_err();
 75                let buffer = project
 76                    .update(&mut cx, |project, cx| {
 77                        project.create_buffer("", markdown, cx)
 78                    })?
 79                    .expect("creating buffers on a local workspace always succeeds");
 80
 81                workspace.update(&mut cx, |workspace, cx| {
 82                    let system_specs = SystemSpecs::new(cx);
 83
 84                    workspace.toggle_modal(cx, move |cx| {
 85                        FeedbackModal::new(system_specs, project, buffer, cx)
 86                    });
 87                })?;
 88
 89                anyhow::Ok(())
 90            })
 91            .detach_and_log_err(cx);
 92        });
 93    }
 94
 95    pub fn new(
 96        system_specs: SystemSpecs,
 97        project: Model<Project>,
 98        buffer: Model<Buffer>,
 99        cx: &mut ViewContext<Self>,
100    ) -> Self {
101        let email_address_editor = cx.build_view(|cx| {
102            let mut editor = Editor::single_line(cx);
103            editor.set_placeholder_text("Email address (optional)", cx);
104
105            if let Ok(Some(email_address)) = KEY_VALUE_STORE.read_kvp(DATABASE_KEY_NAME) {
106                editor.set_text(email_address, cx)
107            }
108
109            editor
110        });
111
112        let feedback_editor = cx.build_view(|cx| {
113            let mut editor = Editor::for_buffer(buffer, Some(project.clone()), cx);
114            editor.set_placeholder_text(
115                "You can use markdown to organize your feedback wiht add code and links, or organize feedback.",
116                cx,
117            );
118            // editor.set_show_gutter(false, cx);
119            editor.set_vertical_scroll_margin(5, cx);
120            editor
121        });
122
123        cx.subscribe(
124            &feedback_editor,
125            |this, editor, event: &EditorEvent, cx| match event {
126                EditorEvent::Edited => {
127                    this.character_count = editor
128                        .read(cx)
129                        .buffer()
130                        .read(cx)
131                        .as_singleton()
132                        .expect("Feedback editor is never a multi-buffer")
133                        .read(cx)
134                        .len();
135                    cx.notify();
136                }
137                _ => {}
138            },
139        )
140        .detach();
141
142        Self {
143            system_specs: system_specs.clone(),
144            feedback_editor,
145            email_address_editor,
146            pending_submission: false,
147            character_count: 0,
148        }
149    }
150
151    pub fn submit(&mut self, cx: &mut ViewContext<Self>) -> Task<anyhow::Result<()>> {
152        let feedback_text = self.feedback_editor.read(cx).text(cx).trim().to_string();
153        let email = self.email_address_editor.read(cx).text_option(cx);
154
155        let answer = cx.prompt(
156            PromptLevel::Info,
157            "Ready to submit your feedback?",
158            &["Yes, Submit!", "No"],
159        );
160        let client = cx.global::<Arc<Client>>().clone();
161        let specs = self.system_specs.clone();
162        cx.spawn(|this, mut cx| async move {
163            let answer = answer.await.ok();
164            if answer == Some(0) {
165                match email.clone() {
166                    Some(email) => {
167                        let _ = KEY_VALUE_STORE
168                            .write_kvp(DATABASE_KEY_NAME.to_string(), email)
169                            .await;
170                    }
171                    None => {
172                        let _ = KEY_VALUE_STORE
173                            .delete_kvp(DATABASE_KEY_NAME.to_string())
174                            .await;
175                    }
176                };
177
178                this.update(&mut cx, |feedback_editor, cx| {
179                    feedback_editor.set_pending_submission(true, cx);
180                })
181                .log_err();
182
183                if let Err(error) =
184                    FeedbackModal::submit_feedback(&feedback_text, email, client, specs).await
185                {
186                    log::error!("{}", error);
187                    this.update(&mut cx, |feedback_editor, cx| {
188                        let prompt = cx.prompt(
189                            PromptLevel::Critical,
190                            FEEDBACK_SUBMISSION_ERROR_TEXT,
191                            &["OK"],
192                        );
193                        cx.spawn(|_, _cx| async move {
194                            prompt.await.ok();
195                        })
196                        .detach();
197                        feedback_editor.set_pending_submission(false, cx);
198                    })
199                    .log_err();
200                }
201            }
202        })
203        .detach();
204        Task::ready(Ok(()))
205    }
206
207    fn set_pending_submission(&mut self, pending_submission: bool, cx: &mut ViewContext<Self>) {
208        self.pending_submission = pending_submission;
209        cx.notify();
210    }
211
212    async fn submit_feedback(
213        feedback_text: &str,
214        email: Option<String>,
215        zed_client: Arc<Client>,
216        system_specs: SystemSpecs,
217    ) -> anyhow::Result<()> {
218        let feedback_endpoint = format!("{}/api/feedback", *ZED_SERVER_URL);
219        let telemetry = zed_client.telemetry();
220        let metrics_id = telemetry.metrics_id();
221        let installation_id = telemetry.installation_id();
222        let is_staff = telemetry.is_staff();
223        let http_client = zed_client.http_client();
224        let request = FeedbackRequestBody {
225            feedback_text: &feedback_text,
226            email,
227            metrics_id,
228            installation_id,
229            system_specs,
230            is_staff: is_staff.unwrap_or(false),
231            token: ZED_SECRET_CLIENT_TOKEN,
232        };
233        let json_bytes = serde_json::to_vec(&request)?;
234        let request = Request::post(feedback_endpoint)
235            .header("content-type", "application/json")
236            .body(json_bytes.into())?;
237        let mut response = http_client.send(request).await?;
238        let mut body = String::new();
239        response.body_mut().read_to_string(&mut body).await?;
240        let response_status = response.status();
241        if !response_status.is_success() {
242            bail!("Feedback API failed with error: {}", response_status)
243        }
244        Ok(())
245    }
246
247    // TODO: Escape button calls dismiss
248    // TODO: Should do same as hitting cancel / clicking outside of modal
249    //     Close immediately if no text in field
250    //     Ask to close if text in the field
251    fn cancel(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
252        Self::dismiss_event(cx)
253    }
254
255    fn dismiss_event(cx: &mut ViewContext<Self>) {
256        let dismiss = Self::prompt_dismiss(cx);
257
258        cx.spawn(|this, mut cx| async move {
259            if dismiss.await {
260                this.update(&mut cx, |_, cx| cx.emit(DismissEvent)).ok();
261            }
262        })
263        .detach()
264    }
265
266    fn prompt_dismiss(cx: &mut ViewContext<Self>) -> impl Future<Output = bool> {
267        let answer = cx.prompt(PromptLevel::Info, "Discard feedback?", &["Yes", "No"]);
268
269        async {
270            let answer = answer.await.ok();
271            answer == Some(0)
272        }
273    }
274}
275
276impl Render for FeedbackModal {
277    type Element = Div;
278
279    fn render(&mut self, cx: &mut ViewContext<Self>) -> Self::Element {
280        let valid_email_address = match self.email_address_editor.read(cx).text_option(cx) {
281            Some(email_address) => Regex::new(EMAIL_REGEX).unwrap().is_match(&email_address),
282            None => true,
283        };
284
285        let valid_character_count = FEEDBACK_CHAR_LIMIT.contains(&self.character_count);
286
287        let allow_submission =
288            valid_character_count && valid_email_address && !self.pending_submission;
289
290        let has_feedback = self.feedback_editor.read(cx).text_option(cx).is_some();
291
292        let submit_button_text = if self.pending_submission {
293            "Submitting..."
294        } else {
295            "Submit"
296        };
297
298        let open_community_repo =
299            cx.listener(|_, _, cx| cx.dispatch_action(Box::new(OpenZedCommunityRepo)));
300
301        // Moved this here because providing it inline breaks rustfmt
302        let provide_an_email_address =
303            "Provide an email address if you want us to be able to reply.";
304
305        v_stack()
306            .elevation_3(cx)
307            .key_context("GiveFeedback")
308            .on_action(cx.listener(Self::cancel))
309            .min_w(rems(40.))
310            .max_w(rems(96.))
311            .h(rems(32.))
312            .p_4()
313            .gap_4()
314            .child(v_stack().child(
315                // TODO: Add Headline component to `ui2`
316                div().text_xl().child("Share Feedback"),
317            ))
318            .child(
319                Label::new(if self.character_count < *FEEDBACK_CHAR_LIMIT.start() {
320                    format!(
321                        "Feedback must be at least {} characters.",
322                        FEEDBACK_CHAR_LIMIT.start()
323                    )
324                } else if self.character_count > *FEEDBACK_CHAR_LIMIT.end() {
325                    format!(
326                        "Feedback must be less than {} characters.",
327                        FEEDBACK_CHAR_LIMIT.end()
328                    )
329                } else {
330                    format!(
331                        "Characters: {}",
332                        *FEEDBACK_CHAR_LIMIT.end() - self.character_count
333                    )
334                })
335                .color(if valid_character_count {
336                    Color::Success
337                } else {
338                    Color::Error
339                }),
340            )
341            .child(
342                div()
343                    .flex_1()
344                    .bg(cx.theme().colors().editor_background)
345                    .p_2()
346                    .border()
347                    .rounded_md()
348                    .border_color(cx.theme().colors().border)
349                    .child(self.feedback_editor.clone()),
350            )
351            .child(
352                div()
353                    .child(
354                        h_stack()
355                            .bg(cx.theme().colors().editor_background)
356                            .p_2()
357                            .border()
358                            .rounded_md()
359                            .border_color(cx.theme().colors().border)
360                            .child(self.email_address_editor.clone()),
361                    )
362                    .child(
363                        h_stack()
364                            .justify_between()
365                            .gap_1()
366                            .child(
367                                Button::new("community_repo", "Community Repo")
368                                    .style(ButtonStyle::Transparent)
369                                    .icon(Icon::ExternalLink)
370                                    .icon_position(IconPosition::End)
371                                    .icon_size(IconSize::Small)
372                                    .on_click(open_community_repo),
373                            )
374                            .child(
375                                h_stack()
376                                    .gap_1()
377                                    .child(
378                                        Button::new("cancel_feedback", "Cancel")
379                                            .style(ButtonStyle::Subtle)
380                                            .color(Color::Muted)
381                                            // TODO: replicate this logic when clicking outside the modal
382                                            // TODO: Will require somehow overriding the modal dismal default behavior
383                                            .map(|this| {
384                                                if has_feedback {
385                                                    this.on_click(cx.listener(|_, _, cx| {
386                                                        Self::dismiss_event(cx)
387                                                    }))
388                                                } else {
389                                                    this.on_click(cx.listener(|_, _, cx| {
390                                                        cx.emit(DismissEvent);
391                                                    }))
392                                                }
393                                            }),
394                                    )
395                                    .child(
396                                        Button::new("send_feedback", submit_button_text)
397                                            .color(Color::Accent)
398                                            .style(ButtonStyle::Filled)
399                                            // TODO: Ensure that while submitting, "Sending..." is shown and disable the button
400                                            // TODO: If submit errors: show popup with error, don't close modal, set text back to "Submit", and re-enable button
401                                            // TODO: If submit is successful, close the modal
402                                            .on_click(cx.listener(|this, _, cx| {
403                                                let _ = this.submit(cx);
404                                            }))
405                                            .tooltip(move |cx| {
406                                                Tooltip::with_meta(
407                                                    "Submit feedback to the Zed team.",
408                                                    None,
409                                                    provide_an_email_address,
410                                                    cx,
411                                                )
412                                            })
413                                            .when(!allow_submission, |this| this.disabled(true)),
414                                    ),
415                            ),
416                    ),
417            )
418    }
419}
420
421// TODO: Add compilation flags to enable debug mode, where we can simulate sending feedback that both succeeds and fails, so we can test the UI
422// TODO: Maybe store email address whenever the modal is closed, versus just on submit, so users can remove it if they want without submitting