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
236impl Render for FeedbackModal {
237    type Element = Div;
238
239    fn render(&mut self, cx: &mut ViewContext<Self>) -> Self::Element {
240        let valid_email_address = match self.email_address_editor.read(cx).text_option(cx) {
241            Some(email_address) => Regex::new(EMAIL_REGEX).unwrap().is_match(&email_address),
242            None => true,
243        };
244
245        let valid_character_count = FEEDBACK_CHAR_LIMIT.contains(&self.character_count);
246        let characters_remaining =
247            if valid_character_count || self.character_count > *FEEDBACK_CHAR_LIMIT.end() {
248                *FEEDBACK_CHAR_LIMIT.end() as i32 - self.character_count as i32
249            } else {
250                self.character_count as i32 - *FEEDBACK_CHAR_LIMIT.start() as i32
251            };
252
253        let allow_submission =
254            valid_character_count && valid_email_address && !self.pending_submission;
255
256        let dismiss = cx.listener(|_, _, cx| {
257            // TODO
258            // if self.feedback_editor.read(cx).text_option(cx).is_some() {
259            //     let answer = cx.prompt(PromptLevel::Info, "Exit feedback?", &["Yes", "No"]);
260            //     cx.spawn(|_, cx| async move {
261            //         let answer = answer.await.ok();
262            //         if answer == Some(0) {
263            //             cx.emit(DismissEvent);
264            //         }
265            //     })
266            //     .detach();
267            // }
268
269            cx.emit(DismissEvent);
270        });
271        let open_community_repo =
272            cx.listener(|_, _, cx| cx.dispatch_action(Box::new(OpenZedCommunityRepo)));
273
274        v_stack()
275            .elevation_3(cx)
276            .min_w(rems(40.))
277            .max_w(rems(96.))
278            .border()
279            .border_color(red())
280            .h(rems(40.))
281            .p_2()
282            .gap_2()
283            .child(
284                v_stack().child(
285                    div()
286                        .size_full()
287                        .child(Label::new("Give Feedback").color(Color::Default))
288                        .child(Label::new("This editor supports markdown").color(Color::Muted)),
289                ),
290            )
291            .child(
292                div()
293                    .flex_1()
294                    .bg(cx.theme().colors().editor_background)
295                    .border()
296                    .border_color(cx.theme().colors().border)
297                    .child(self.feedback_editor.clone()),
298            )
299            .child(
300                div().child(
301                    Label::new(format!(
302                        "Characters: {}",
303                        characters_remaining
304                    ))
305                    .when(valid_character_count, |this| this.color(Color::Success))
306                    .when(!valid_character_count, |this| this.color(Color::Error))
307                ),
308            )
309            .child(                div()
310                .bg(cx.theme().colors().editor_background)
311                .border()
312                .border_color(cx.theme().colors().border)
313                .child(self.email_address_editor.clone())
314            )
315            .child(
316                h_stack()
317                    .justify_between()
318                    .gap_1()
319                    .child(Button::new("community_repo", "Community Repo")
320                        .style(ButtonStyle::Filled)
321                        .color(Color::Muted)
322                        .on_click(open_community_repo)
323                    )
324                    .child(h_stack().justify_between().gap_1()
325                        .child(
326                            Button::new("cancel_feedback", "Cancel")
327                                .style(ButtonStyle::Subtle)
328                                .color(Color::Muted)
329                                .on_click(dismiss),
330                        )
331                        .child(
332                            Button::new("send_feedback", "Send Feedback")
333                                .color(Color::Accent)
334                                .style(ButtonStyle::Filled)
335                                // TODO - error handling - show modal on error
336                                .on_click(cx.listener(|this, _, cx| {let _ = this.submit(cx);}))
337                                .tooltip(|cx| {
338                                    Tooltip::with_meta(
339                                        "Submit feedback to the Zed team.",
340                                        None,
341                                        "Provide an email address if you want us to be able to reply.",
342                                        cx,
343                                    )
344                                })
345                                .when(!allow_submission, |this| this.disabled(true)),
346                        ),
347                    )
348
349            )
350    }
351}