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;
  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, Label, Tooltip};
 18use util::ResultExt;
 19use workspace::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 FeedbackModal {
 56    pub fn register(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) {
 57        let _handle = cx.view().downgrade();
 58        workspace.register_action(move |workspace, _: &GiveFeedback, cx| {
 59            let markdown = workspace
 60                .app_state()
 61                .languages
 62                .language_for_name("Markdown");
 63
 64            let project = workspace.project().clone();
 65
 66            cx.spawn(|workspace, mut cx| async move {
 67                let markdown = markdown.await.log_err();
 68                let buffer = project
 69                    .update(&mut cx, |project, cx| {
 70                        project.create_buffer("", markdown, cx)
 71                    })?
 72                    .expect("creating buffers on a local workspace always succeeds");
 73
 74                workspace.update(&mut cx, |workspace, cx| {
 75                    let system_specs = SystemSpecs::new(cx);
 76
 77                    workspace.toggle_modal(cx, move |cx| {
 78                        FeedbackModal::new(system_specs, project, buffer, cx)
 79                    });
 80                })?;
 81
 82                anyhow::Ok(())
 83            })
 84            .detach_and_log_err(cx);
 85        });
 86    }
 87
 88    pub fn new(
 89        system_specs: SystemSpecs,
 90        project: Model<Project>,
 91        buffer: Model<Buffer>,
 92        cx: &mut ViewContext<Self>,
 93    ) -> Self {
 94        let email_address_editor = cx.build_view(|cx| {
 95            let mut editor = Editor::single_line(cx);
 96            editor.set_placeholder_text("Email address (optional)", cx);
 97
 98            if let Ok(Some(email_address)) = KEY_VALUE_STORE.read_kvp(DATABASE_KEY_NAME) {
 99                editor.set_text(email_address, cx)
100            }
101
102            editor
103        });
104
105        let feedback_editor = cx.build_view(|cx| {
106            let mut editor = Editor::for_buffer(buffer, Some(project.clone()), cx);
107            editor.set_vertical_scroll_margin(5, cx);
108            editor
109        });
110
111        cx.subscribe(
112            &feedback_editor,
113            |this, editor, event: &EditorEvent, cx| match event {
114                EditorEvent::Edited => {
115                    this.character_count = editor
116                        .read(cx)
117                        .buffer()
118                        .read(cx)
119                        .as_singleton()
120                        .expect("Feedback editor is never a multi-buffer")
121                        .read(cx)
122                        .len();
123                    cx.notify();
124                }
125                _ => {}
126            },
127        )
128        .detach();
129
130        Self {
131            system_specs: system_specs.clone(),
132            feedback_editor,
133            email_address_editor,
134            pending_submission: false,
135            character_count: 0,
136        }
137    }
138
139    pub fn submit(&mut self, cx: &mut ViewContext<Self>) -> Task<anyhow::Result<()>> {
140        let feedback_text = self.feedback_editor.read(cx).text(cx).trim().to_string();
141        let email = self.email_address_editor.read(cx).text_option(cx);
142
143        let answer = cx.prompt(
144            PromptLevel::Info,
145            "Ready to submit your feedback?",
146            &["Yes, Submit!", "No"],
147        );
148        let client = cx.global::<Arc<Client>>().clone();
149        let specs = self.system_specs.clone();
150        cx.spawn(|this, mut cx| async move {
151            let answer = answer.await.ok();
152            if answer == Some(0) {
153                match email.clone() {
154                    Some(email) => {
155                        let _ = KEY_VALUE_STORE
156                            .write_kvp(DATABASE_KEY_NAME.to_string(), email)
157                            .await;
158                    }
159                    None => {
160                        let _ = KEY_VALUE_STORE
161                            .delete_kvp(DATABASE_KEY_NAME.to_string())
162                            .await;
163                    }
164                };
165
166                this.update(&mut cx, |feedback_editor, cx| {
167                    feedback_editor.set_pending_submission(true, cx);
168                })
169                .log_err();
170
171                if let Err(error) =
172                    FeedbackModal::submit_feedback(&feedback_text, email, client, specs).await
173                {
174                    log::error!("{}", error);
175                    this.update(&mut cx, |feedback_editor, cx| {
176                        let prompt = cx.prompt(
177                            PromptLevel::Critical,
178                            FEEDBACK_SUBMISSION_ERROR_TEXT,
179                            &["OK"],
180                        );
181                        cx.spawn(|_, _cx| async move {
182                            prompt.await.ok();
183                        })
184                        .detach();
185                        feedback_editor.set_pending_submission(false, cx);
186                    })
187                    .log_err();
188                }
189            }
190        })
191        .detach();
192        Task::ready(Ok(()))
193    }
194
195    fn set_pending_submission(&mut self, pending_submission: bool, cx: &mut ViewContext<Self>) {
196        self.pending_submission = pending_submission;
197        cx.notify();
198    }
199
200    async fn submit_feedback(
201        feedback_text: &str,
202        email: Option<String>,
203        zed_client: Arc<Client>,
204        system_specs: SystemSpecs,
205    ) -> anyhow::Result<()> {
206        let feedback_endpoint = format!("{}/api/feedback", *ZED_SERVER_URL);
207        let telemetry = zed_client.telemetry();
208        let metrics_id = telemetry.metrics_id();
209        let installation_id = telemetry.installation_id();
210        let is_staff = telemetry.is_staff();
211        let http_client = zed_client.http_client();
212        let request = FeedbackRequestBody {
213            feedback_text: &feedback_text,
214            email,
215            metrics_id,
216            installation_id,
217            system_specs,
218            is_staff: is_staff.unwrap_or(false),
219            token: ZED_SECRET_CLIENT_TOKEN,
220        };
221        let json_bytes = serde_json::to_vec(&request)?;
222        let request = Request::post(feedback_endpoint)
223            .header("content-type", "application/json")
224            .body(json_bytes.into())?;
225        let mut response = http_client.send(request).await?;
226        let mut body = String::new();
227        response.body_mut().read_to_string(&mut body).await?;
228        let response_status = response.status();
229        if !response_status.is_success() {
230            bail!("Feedback API failed with error: {}", response_status)
231        }
232        Ok(())
233    }
234
235    // TODO: Escape button calls dismiss
236    // TODO: Should do same as hitting cancel / clicking outside of modal
237    //     Close immediately if no text in field
238    //     Ask to close if text in the field
239    fn cancel(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
240        cx.emit(DismissEvent);
241    }
242}
243
244impl Render for FeedbackModal {
245    type Element = Div;
246
247    fn render(&mut self, cx: &mut ViewContext<Self>) -> Self::Element {
248        let valid_email_address = match self.email_address_editor.read(cx).text_option(cx) {
249            Some(email_address) => Regex::new(EMAIL_REGEX).unwrap().is_match(&email_address),
250            None => true,
251        };
252
253        let valid_character_count = FEEDBACK_CHAR_LIMIT.contains(&self.character_count);
254        let characters_remaining =
255            if valid_character_count || self.character_count > *FEEDBACK_CHAR_LIMIT.end() {
256                *FEEDBACK_CHAR_LIMIT.end() as i32 - self.character_count as i32
257            } else {
258                self.character_count as i32 - *FEEDBACK_CHAR_LIMIT.start() as i32
259            };
260
261        let allow_submission =
262            valid_character_count && valid_email_address && !self.pending_submission;
263
264        let has_feedback = self.feedback_editor.read(cx).text_option(cx).is_some();
265
266        let submit_button_text = if self.pending_submission {
267            "Sending..."
268        } else {
269            "Send Feedback"
270        };
271        let dismiss = cx.listener(|_, _, cx| {
272            cx.emit(DismissEvent);
273        });
274        // TODO: get the "are you sure you want to dismiss?" prompt here working
275        let dismiss_prompt = cx.listener(|_, _, _| {
276            // let answer = cx.prompt(PromptLevel::Info, "Exit feedback?", &["Yes", "No"]);
277            // cx.spawn(|_, _| async move {
278            //     let answer = answer.await.ok();
279            //     if answer == Some(0) {
280            //         cx.emit(DismissEvent);
281            //     }
282            // })
283            // .detach();
284        });
285        let open_community_repo =
286            cx.listener(|_, _, cx| cx.dispatch_action(Box::new(OpenZedCommunityRepo)));
287
288        // TODO: Nate UI pass
289        v_stack()
290            .elevation_3(cx)
291            .key_context("GiveFeedback")
292            .on_action(cx.listener(Self::cancel))
293            .min_w(rems(40.))
294            .max_w(rems(96.))
295            .border()
296            .border_color(red())
297            .h(rems(40.))
298            .p_2()
299            .gap_2()
300            .child(
301                v_stack().child(
302                    div()
303                        .size_full()
304                        .child(Label::new("Give Feedback").color(Color::Default))
305                        .child(Label::new("This editor supports markdown").color(Color::Muted)),
306                ),
307            )
308            .child(
309                div()
310                    .flex_1()
311                    .bg(cx.theme().colors().editor_background)
312                    .border()
313                    .border_color(cx.theme().colors().border)
314                    .child(self.feedback_editor.clone()),
315            )
316            .child(
317                div().child(
318                    Label::new(format!(
319                        "Characters: {}",
320                        characters_remaining
321                    ))
322                    .color(
323                        if valid_character_count {
324                            Color::Success
325                        } else {
326                            Color::Error
327                        }
328                    )
329                ),
330            )
331            .child(
332                div()
333                .bg(cx.theme().colors().editor_background)
334                .border()
335                .border_color(cx.theme().colors().border)
336                .child(self.email_address_editor.clone())
337            )
338            .child(
339                h_stack()
340                    .justify_between()
341                    .gap_1()
342                    .child(Button::new("community_repo", "Community Repo")
343                        .style(ButtonStyle::Filled)
344                        .color(Color::Muted)
345                        .on_click(open_community_repo)
346                    )
347                    .child(h_stack().justify_between().gap_1()
348                        .child(
349                            Button::new("cancel_feedback", "Cancel")
350                                .style(ButtonStyle::Subtle)
351                                .color(Color::Muted)
352                                // TODO: replicate this logic when clicking outside the modal
353                                // TODO: Will require somehow overriding the modal dismal default behavior
354                                .map(|this| {
355                                    if has_feedback {
356                                        this.on_click(dismiss_prompt)
357                                    } else {
358                                        this.on_click(dismiss)
359                                    }
360                                })
361                        )
362                        .child(
363                            Button::new("send_feedback", submit_button_text)
364                                .color(Color::Accent)
365                                .style(ButtonStyle::Filled)
366                                // TODO: Ensure that while submitting, "Sending..." is shown and disable the button
367                                // TODO: If submit errors: show popup with error, don't close modal, set text back to "Send Feedback", and re-enable button
368                                // TODO: If submit is successful, close the modal
369                                .on_click(cx.listener(|this, _, cx| {
370                                    let _ = this.submit(cx);
371                                }))
372                                .tooltip(|cx| {
373                                    Tooltip::with_meta(
374                                        "Submit feedback to the Zed team.",
375                                        None,
376                                        "Provide an email address if you want us to be able to reply.",
377                                        cx,
378                                    )
379                                })
380                                .when(!allow_submission, |this| this.disabled(true))
381                        ),
382                    )
383
384            )
385    }
386}