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