diff --git a/apps/desktop/src-tauri/src/commands.rs b/apps/desktop/src-tauri/src/commands.rs index cce8471400..f2c1aeec91 100644 --- a/apps/desktop/src-tauri/src/commands.rs +++ b/apps/desktop/src-tauri/src/commands.rs @@ -1,5 +1,6 @@ use crate::AppExt; use crate::embedded_cli::EmbeddedCliStatus; +use crate::ext::OnboardingSurveyState; #[tauri::command] #[specta::specta] @@ -110,6 +111,38 @@ pub async fn set_recently_opened_sessions( app.set_recently_opened_sessions(v) } +#[tauri::command] +#[specta::specta] +pub async fn get_onboarding_survey_state( + app: tauri::AppHandle, +) -> Result { + app.get_onboarding_survey_state() +} + +#[tauri::command] +#[specta::specta] +pub async fn record_onboarding_survey_launch( + app: tauri::AppHandle, +) -> Result { + app.record_onboarding_survey_launch() +} + +#[tauri::command] +#[specta::specta] +pub async fn finish_onboarding_survey( + app: tauri::AppHandle, +) -> Result { + app.finish_onboarding_survey() +} + +#[tauri::command] +#[specta::specta] +pub async fn reset_onboarding_survey( + app: tauri::AppHandle, +) -> Result { + app.reset_onboarding_survey() +} + #[tauri::command] #[specta::specta] pub async fn get_char_v1p1_preview( diff --git a/apps/desktop/src-tauri/src/ext.rs b/apps/desktop/src-tauri/src/ext.rs index 6de3fb1550..797dc3bdee 100644 --- a/apps/desktop/src-tauri/src/ext.rs +++ b/apps/desktop/src-tauri/src/ext.rs @@ -1,5 +1,13 @@ use crate::StoreKey; use tauri_plugin_store2::{ScopedStore, Store2PluginExt}; + +#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize, specta::Type)] +#[serde(default, rename_all = "camelCase")] +pub struct OnboardingSurveyState { + pub launch_count: u32, + pub done: bool, +} + pub trait AppExt { fn desktop_store(&self) -> Result, String>; @@ -18,6 +26,12 @@ pub trait AppExt { fn get_recently_opened_sessions(&self) -> Result, String>; fn set_recently_opened_sessions(&self, v: String) -> Result<(), String>; + fn get_onboarding_survey_state(&self) -> Result; + fn set_onboarding_survey_state(&self, state: OnboardingSurveyState) -> Result<(), String>; + fn record_onboarding_survey_launch(&self) -> Result; + fn finish_onboarding_survey(&self) -> Result; + fn reset_onboarding_survey(&self) -> Result; + fn get_char_v1p1_preview(&self) -> Result; fn set_char_v1p1_preview(&self, v: bool) -> Result<(), String>; } @@ -115,6 +129,47 @@ impl> AppExt for T { store.save().map_err(|e| e.to_string()) } + #[tracing::instrument(skip_all)] + fn get_onboarding_survey_state(&self) -> Result { + let store = self.desktop_store()?; + store + .get(StoreKey::OnboardingSurvey) + .map(|opt| opt.unwrap_or_default()) + .map_err(|e| e.to_string()) + } + + #[tracing::instrument(skip_all)] + fn set_onboarding_survey_state(&self, state: OnboardingSurveyState) -> Result<(), String> { + let store = self.desktop_store()?; + store + .set(StoreKey::OnboardingSurvey, state) + .map_err(|e| e.to_string())?; + store.save().map_err(|e| e.to_string()) + } + + #[tracing::instrument(skip_all)] + fn record_onboarding_survey_launch(&self) -> Result { + let mut state = self.get_onboarding_survey_state()?; + state.launch_count = state.launch_count.saturating_add(1); + self.set_onboarding_survey_state(state.clone())?; + Ok(state) + } + + #[tracing::instrument(skip_all)] + fn finish_onboarding_survey(&self) -> Result { + let mut state = self.get_onboarding_survey_state()?; + state.done = true; + self.set_onboarding_survey_state(state.clone())?; + Ok(state) + } + + #[tracing::instrument(skip_all)] + fn reset_onboarding_survey(&self) -> Result { + let state = OnboardingSurveyState::default(); + self.set_onboarding_survey_state(state.clone())?; + Ok(state) + } + #[tracing::instrument(skip_all)] fn get_char_v1p1_preview(&self) -> Result { if cfg!(feature = "new") { diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index c892ac0187..78ed2d6876 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -378,6 +378,10 @@ fn make_specta_builder() -> tauri_specta::Builder { commands::set_pinned_tabs::, commands::get_recently_opened_sessions::, commands::set_recently_opened_sessions::, + commands::get_onboarding_survey_state::, + commands::record_onboarding_survey_launch::, + commands::finish_onboarding_survey::, + commands::reset_onboarding_survey::, commands::get_char_v1p1_preview::, commands::set_char_v1p1_preview::, commands::check_embedded_cli::, diff --git a/apps/desktop/src-tauri/src/store.rs b/apps/desktop/src-tauri/src/store.rs index a72fd16bdc..6bea299081 100644 --- a/apps/desktop/src-tauri/src/store.rs +++ b/apps/desktop/src-tauri/src/store.rs @@ -5,6 +5,7 @@ pub enum StoreKey { OnboardingNeeded2, DismissedToasts, OnboardingLocal, + OnboardingSurvey, TinybaseValues, PinnedTabs, RecentlyOpenedSessions, diff --git a/apps/desktop/src/services/event-listeners.test.tsx b/apps/desktop/src/services/event-listeners.test.tsx index 305de53a95..4d801bd0dd 100644 --- a/apps/desktop/src/services/event-listeners.test.tsx +++ b/apps/desktop/src/services/event-listeners.test.tsx @@ -1,3 +1,4 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { render } from "@testing-library/react"; import { beforeEach, describe, expect, test, vi } from "vitest"; @@ -100,7 +101,11 @@ describe("EventListeners notification events", () => { }; useSettingsStoreMock.mockReturnValue(settingsStore as never); - render(); + render( + + + , + ); await vi.waitFor(() => expect(notificationListenMock).toHaveBeenCalledTimes(1), diff --git a/apps/desktop/src/services/event-listeners.tsx b/apps/desktop/src/services/event-listeners.tsx index c7fbab90c4..04d24091c9 100644 --- a/apps/desktop/src/services/event-listeners.tsx +++ b/apps/desktop/src/services/event-listeners.tsx @@ -15,6 +15,7 @@ import { } from "~/store/tinybase/store/sessions"; import * as settings from "~/store/tinybase/store/settings"; import { useTabs } from "~/store/zustand/tabs"; +import { OnboardingSurveyPrompt } from "~/survey/prompt"; function parseIgnoredPlatforms(value: unknown) { if (typeof value !== "string") { @@ -196,5 +197,5 @@ export function EventListeners() { useUpdaterEvents(); useNotificationEvents(); - return null; + return ; } diff --git a/apps/desktop/src/sidebar/devtool.tsx b/apps/desktop/src/sidebar/devtool.tsx index dadf755567..6391756fad 100644 --- a/apps/desktop/src/sidebar/devtool.tsx +++ b/apps/desktop/src/sidebar/devtool.tsx @@ -9,6 +9,11 @@ import { cn } from "@hypr/utils"; import { getLatestVersion } from "~/changelog"; import * as main from "~/store/tinybase/store/main"; import { useTabs } from "~/store/zustand/tabs"; +import { OnboardingSurveyDialog } from "~/survey/dialog"; +import { + useOnboardingSurveyState, + useResetOnboardingSurvey, +} from "~/survey/state"; import { commands } from "~/types/tauri.gen"; export function DevtoolView() { @@ -17,6 +22,7 @@ export function DevtoolView() {
+
@@ -284,6 +290,62 @@ function CountdownTestCard() { ); } +function SurveyCard() { + const [previewOpen, setPreviewOpen] = useState(false); + const { data: surveyState, refetch, isFetching } = useOnboardingSurveyState(); + const resetMutation = useResetOnboardingSurvey(); + + const btnClass = cn([ + "w-full rounded-md px-2.5 py-1.5", + "text-left text-xs font-medium", + "border border-neutral-200 text-neutral-700", + "cursor-pointer transition-colors", + "hover:border-neutral-300 hover:bg-neutral-50", + "disabled:cursor-not-allowed disabled:opacity-40", + ]); + + return ( + +
+
+ Launch count: {surveyState.launchCount} | Done:{" "} + {surveyState.done ? "yes" : "no"} +
+ + + +
+ {previewOpen ? ( + setPreviewOpen(false)} + /> + ) : null} +
+ ); +} + function ErrorTestCard() { const [shouldThrow, setShouldThrow] = useState(false); diff --git a/apps/desktop/src/survey/config.ts b/apps/desktop/src/survey/config.ts new file mode 100644 index 0000000000..e7d6ba2494 --- /dev/null +++ b/apps/desktop/src/survey/config.ts @@ -0,0 +1,125 @@ +import type { AnalyticsPayload } from "@hypr/plugin-analytics"; + +export const ONBOARDING_SURVEY_ID = "019d7b82-451a-0000-e4c8-3a53dd3f2435"; + +export type SurveyQuestion = { + id: string; + prompt: string; + options: string[]; + multiSelect: boolean; + hasOpenChoice?: boolean; +}; + +export type SurveyResponses = Partial>; + +export const onboardingSurveyQuestions: SurveyQuestion[] = [ + { + id: "7405e454-508b-4e2a-80a7-e04e84f0bbaa", + prompt: "How did you find us?", + options: [ + "Search engine (Google, Bing, etc)", + "AI assistant (like ChatGPT)", + "Social media (X, LinkedIn, YouTube, etc)", + "Referral from a friend or colleague", + "GitHub", + "Other", + ], + multiSelect: false, + hasOpenChoice: true, + }, + { + id: "a9b47320-a169-4f93-90e6-15375fed4e8d", + prompt: "Why did you decide to use Char?", + options: [ + "I want my data stored locally / privacy matters", + "I want to choose my own AI provider", + "It's open source", + "I was looking for a free AI meeting tool", + "Other", + ], + multiSelect: true, + hasOpenChoice: true, + }, + { + id: "ce59d931-ed0d-4d23-9ab5-55656a1e638a", + prompt: "What best describes your role?", + options: [ + "Engineer / Developer", + "Founder / Executive", + "Product", + "Design", + "Operations", + "Sales / Marketing / Customer Success", + "Research / Education / Student", + "Other", + ], + multiSelect: false, + hasOpenChoice: true, + }, + { + id: "d58b62d0-c689-4c72-877d-fa949b30ca47", + prompt: "How have you been taking notes?", + options: [ + "I'm not / pen & paper", + "Manually in an app (Apple Notes, Notion, Google Docs, etc.)", + "AI tool that joins the call (Otter, Fireflies, etc.)", + "AI tool without a bot (Granola, Jamie, etc.)", + "Other", + ], + multiSelect: true, + hasOpenChoice: true, + }, +]; + +function surveyResponseKey(question: SurveyQuestion) { + return `$survey_response_${question.id}`; +} + +function formatSurveyResponse(question: SurveyQuestion, answers: string[]) { + if (!question.multiSelect) { + return answers[0] ?? ""; + } + + return answers; +} + +function buildSurveyQuestionsPayload(responses: SurveyResponses) { + return onboardingSurveyQuestions.map((question) => ({ + id: question.id, + question: question.prompt, + response: formatSurveyResponse(question, responses[question.id] ?? []), + })); +} + +export function buildOnboardingSurveySubmittedPayload( + responses: SurveyResponses, +): AnalyticsPayload { + const payload: AnalyticsPayload = { + event: "survey sent", + $survey_id: ONBOARDING_SURVEY_ID, + $survey_questions: buildSurveyQuestionsPayload(responses), + }; + + onboardingSurveyQuestions.forEach((question) => { + payload[surveyResponseKey(question)] = formatSurveyResponse( + question, + responses[question.id] ?? [], + ); + }); + + return payload; +} + +export function buildOnboardingSurveyShownPayload(): AnalyticsPayload { + return { + event: "survey shown", + $survey_id: ONBOARDING_SURVEY_ID, + }; +} + +export function buildOnboardingSurveyDismissedPayload(): AnalyticsPayload { + return { + event: "survey dismissed", + $survey_id: ONBOARDING_SURVEY_ID, + }; +} diff --git a/apps/desktop/src/survey/dialog.tsx b/apps/desktop/src/survey/dialog.tsx new file mode 100644 index 0000000000..001f35e4a3 --- /dev/null +++ b/apps/desktop/src/survey/dialog.tsx @@ -0,0 +1,328 @@ +import { CheckIcon } from "lucide-react"; +import { useRef, useState } from "react"; + +import { Button } from "@hypr/ui/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@hypr/ui/components/ui/dialog"; +import { Input } from "@hypr/ui/components/ui/input"; +import { cn } from "@hypr/utils"; + +import { + onboardingSurveyQuestions, + type SurveyQuestion, + type SurveyResponses, +} from "./config"; + +const OPEN_CHOICE_PLACEHOLDER = "Please specify"; + +function getOpenChoiceOption(question: SurveyQuestion) { + if (!question.hasOpenChoice) { + return null; + } + + return question.options[question.options.length - 1] ?? null; +} + +function resolveQuestionResponses( + question: SurveyQuestion, + selectedOptions: string[], + openChoiceValue: string, +) { + const openChoiceOption = getOpenChoiceOption(question); + const trimmedOpenChoiceValue = openChoiceValue.trim(); + + return selectedOptions.reduce((resolved, option) => { + if (option !== openChoiceOption) { + resolved.push(option); + return resolved; + } + + if (trimmedOpenChoiceValue.length > 0) { + resolved.push(trimmedOpenChoiceValue); + } + + return resolved; + }, []); +} + +function OptionButton({ + label, + selected, + onClick, +}: { + label: string; + selected: boolean; + onClick: () => void; +}) { + return ( + + ); +} + +function OpenChoiceOption({ + questionPrompt, + value, + placeholder, + onChange, + onClear, +}: { + questionPrompt: string; + value: string; + placeholder: string; + onChange: (value: string) => void; + onClear: () => void; +}) { + const inputRef = useRef(null); + + return ( +
inputRef.current?.focus()} + > + + onChange(event.target.value)} + placeholder={placeholder} + aria-label={`${questionPrompt} other response`} + className="h-auto border-0 bg-transparent px-0 py-0 text-sm text-white shadow-none placeholder:text-neutral-300 focus-visible:ring-0" + /> +
+ ); +} + +function QuestionStep({ + question, + responses, + openChoiceValue, + onToggle, + onOpenChoiceChange, +}: { + question: SurveyQuestion; + responses: string[]; + openChoiceValue: string; + onToggle: (option: string) => void; + onOpenChoiceChange: (value: string) => void; +}) { + const openChoiceOption = getOpenChoiceOption(question); + + return ( +
+

+ {question.prompt} +

+ {question.multiSelect ? ( +

Select all that apply

+ ) : null} +
+ {question.options.map((option) => { + const isOpenChoice = option === openChoiceOption; + const selected = responses.includes(option); + + return ( +
+ {isOpenChoice && selected ? ( + onToggle(option)} + /> + ) : ( + onToggle(option)} + /> + )} +
+ ); + })} +
+
+ ); +} + +export function OnboardingSurveyDialog({ + open, + onOpenChange, + onSubmit, + submitting = false, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + onSubmit: (responses: SurveyResponses) => void; + submitting?: boolean; +}) { + const [step, setStep] = useState(0); + const [responses, setResponses] = useState({}); + const [openChoiceResponses, setOpenChoiceResponses] = useState< + Partial> + >({}); + + const currentQuestion = onboardingSurveyQuestions[step]; + const currentResponses = responses[currentQuestion.id] ?? []; + const currentOpenChoiceValue = openChoiceResponses[currentQuestion.id] ?? ""; + const currentOpenChoiceOption = getOpenChoiceOption(currentQuestion); + const isLastStep = step === onboardingSurveyQuestions.length - 1; + const currentQuestionNeedsOpenChoice = + currentOpenChoiceOption !== null && + currentResponses.includes(currentOpenChoiceOption) && + currentOpenChoiceValue.trim().length === 0; + const canContinue = + currentResponses.length > 0 && + !currentQuestionNeedsOpenChoice && + !submitting; + + const handleToggle = (option: string) => { + setResponses((current) => { + const selected = current[currentQuestion.id] ?? []; + + if (currentQuestion.multiSelect) { + return { + ...current, + [currentQuestion.id]: selected.includes(option) + ? selected.filter((value) => value !== option) + : [...selected, option], + }; + } + + return { + ...current, + [currentQuestion.id]: [option], + }; + }); + }; + + const buildSurveyResponses = (): SurveyResponses => + onboardingSurveyQuestions.reduce( + (nextResponses, question) => { + const resolvedResponses = resolveQuestionResponses( + question, + responses[question.id] ?? [], + openChoiceResponses[question.id] ?? "", + ); + + if (resolvedResponses.length > 0) { + nextResponses[question.id] = resolvedResponses; + } + + return nextResponses; + }, + {}, + ); + + const handlePrimary = () => { + if (!canContinue) { + return; + } + + if (isLastStep) { + onSubmit(buildSurveyResponses()); + return; + } + + setStep((current) => current + 1); + }; + + return ( + + + + + Quick survey + + + Help us make Char better for you. + + + +
+ + setOpenChoiceResponses((current) => ({ + ...current, + [currentQuestion.id]: value, + })) + } + /> +
+ + +
+
+ {onboardingSurveyQuestions.map((question, index) => ( + + ))} +
+
+ {step > 0 ? ( + + ) : null} + +
+
+
+
+
+ ); +} diff --git a/apps/desktop/src/survey/prompt.test.tsx b/apps/desktop/src/survey/prompt.test.tsx new file mode 100644 index 0000000000..6bd92865e0 --- /dev/null +++ b/apps/desktop/src/survey/prompt.test.tsx @@ -0,0 +1,220 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; + +describe("OnboardingSurveyPrompt", () => { + test("opens on the second launch and submits ID-based responses", async () => { + vi.resetModules(); + + const { commands: tauriCommands } = await import("~/types/tauri.gen"); + const { commands: analyticsCommands } = + await import("@hypr/plugin-analytics"); + const { OnboardingSurveyPrompt } = await import("./prompt"); + + vi.mocked(tauriCommands.recordOnboardingSurveyLaunch).mockResolvedValue({ + status: "ok", + data: { launchCount: 2, done: false }, + }); + vi.mocked(tauriCommands.finishOnboardingSurvey).mockResolvedValue({ + status: "ok", + data: { launchCount: 2, done: true }, + }); + + render( + + + , + ); + + await screen.findByText("How did you find us?"); + await waitFor(() => { + expect(analyticsCommands.event).toHaveBeenCalledWith({ + event: "survey shown", + $survey_id: "019d7b82-451a-0000-e4c8-3a53dd3f2435", + }); + }); + + fireEvent.click( + screen.getByRole("button", { + name: "Search engine (Google, Bing, etc)", + }), + ); + fireEvent.click(screen.getByRole("button", { name: "Next" })); + + fireEvent.click( + screen.getByRole("button", { + name: "I want to choose my own AI provider", + }), + ); + fireEvent.click(screen.getByRole("button", { name: "It's open source" })); + fireEvent.click(screen.getByRole("button", { name: "Next" })); + + fireEvent.click( + screen.getByRole("button", { name: "Founder / Executive" }), + ); + fireEvent.click(screen.getByRole("button", { name: "Next" })); + + fireEvent.click( + screen.getByRole("button", { + name: "AI tool without a bot (Granola, Jamie, etc.)", + }), + ); + fireEvent.click(screen.getByRole("button", { name: "Submit" })); + + await waitFor(() => { + expect(analyticsCommands.event).toHaveBeenCalledWith({ + event: "survey sent", + $survey_id: "019d7b82-451a-0000-e4c8-3a53dd3f2435", + $survey_questions: [ + { + id: "7405e454-508b-4e2a-80a7-e04e84f0bbaa", + question: "How did you find us?", + response: "Search engine (Google, Bing, etc)", + }, + { + id: "a9b47320-a169-4f93-90e6-15375fed4e8d", + question: "Why did you decide to use Char?", + response: [ + "I want to choose my own AI provider", + "It's open source", + ], + }, + { + id: "ce59d931-ed0d-4d23-9ab5-55656a1e638a", + question: "What best describes your role?", + response: "Founder / Executive", + }, + { + id: "d58b62d0-c689-4c72-877d-fa949b30ca47", + question: "How have you been taking notes?", + response: ["AI tool without a bot (Granola, Jamie, etc.)"], + }, + ], + "$survey_response_7405e454-508b-4e2a-80a7-e04e84f0bbaa": + "Search engine (Google, Bing, etc)", + "$survey_response_a9b47320-a169-4f93-90e6-15375fed4e8d": [ + "I want to choose my own AI provider", + "It's open source", + ], + "$survey_response_ce59d931-ed0d-4d23-9ab5-55656a1e638a": + "Founder / Executive", + "$survey_response_d58b62d0-c689-4c72-877d-fa949b30ca47": [ + "AI tool without a bot (Granola, Jamie, etc.)", + ], + }); + }); + expect(tauriCommands.finishOnboardingSurvey).toHaveBeenCalledTimes(1); + }); + + test("submits typed open-choice responses instead of the Other label", async () => { + vi.resetModules(); + + const { commands: tauriCommands } = await import("~/types/tauri.gen"); + const { commands: analyticsCommands } = + await import("@hypr/plugin-analytics"); + const { OnboardingSurveyPrompt } = await import("./prompt"); + + vi.mocked(tauriCommands.recordOnboardingSurveyLaunch).mockResolvedValue({ + status: "ok", + data: { launchCount: 2, done: false }, + }); + vi.mocked(tauriCommands.finishOnboardingSurvey).mockResolvedValue({ + status: "ok", + data: { launchCount: 2, done: true }, + }); + + render( + + + , + ); + + await screen.findByText("How did you find us?"); + + fireEvent.click(screen.getByRole("button", { name: "Other" })); + const discoveryOtherInput = screen.getByRole("textbox", { + name: "How did you find us? other response", + }); + expect(screen.queryByRole("button", { name: "Other" })).toBeNull(); + expect(discoveryOtherInput.getAttribute("placeholder")).toBe( + "Please specify", + ); + expect(document.activeElement).toBe(discoveryOtherInput); + fireEvent.change(discoveryOtherInput, { + target: { value: "Newsletter" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Next" })); + + fireEvent.click(screen.getByRole("button", { name: "It's open source" })); + fireEvent.click(screen.getByRole("button", { name: "Other" })); + fireEvent.change( + screen.getByRole("textbox", { + name: "Why did you decide to use Char? other response", + }), + { + target: { value: "No meeting bot" }, + }, + ); + fireEvent.click(screen.getByRole("button", { name: "Next" })); + + fireEvent.click(screen.getByRole("button", { name: "Other" })); + fireEvent.change( + screen.getByRole("textbox", { + name: "What best describes your role? other response", + }), + { + target: { value: "Attorney" }, + }, + ); + fireEvent.click(screen.getByRole("button", { name: "Next" })); + + fireEvent.click(screen.getByRole("button", { name: "Other" })); + fireEvent.change( + screen.getByRole("textbox", { + name: "How have you been taking notes? other response", + }), + { + target: { value: "Voice memos" }, + }, + ); + fireEvent.click(screen.getByRole("button", { name: "Submit" })); + + await waitFor(() => { + expect(analyticsCommands.event).toHaveBeenCalledWith({ + event: "survey sent", + $survey_id: "019d7b82-451a-0000-e4c8-3a53dd3f2435", + $survey_questions: [ + { + id: "7405e454-508b-4e2a-80a7-e04e84f0bbaa", + question: "How did you find us?", + response: "Newsletter", + }, + { + id: "a9b47320-a169-4f93-90e6-15375fed4e8d", + question: "Why did you decide to use Char?", + response: ["It's open source", "No meeting bot"], + }, + { + id: "ce59d931-ed0d-4d23-9ab5-55656a1e638a", + question: "What best describes your role?", + response: "Attorney", + }, + { + id: "d58b62d0-c689-4c72-877d-fa949b30ca47", + question: "How have you been taking notes?", + response: ["Voice memos"], + }, + ], + "$survey_response_7405e454-508b-4e2a-80a7-e04e84f0bbaa": "Newsletter", + "$survey_response_a9b47320-a169-4f93-90e6-15375fed4e8d": [ + "It's open source", + "No meeting bot", + ], + "$survey_response_ce59d931-ed0d-4d23-9ab5-55656a1e638a": "Attorney", + "$survey_response_d58b62d0-c689-4c72-877d-fa949b30ca47": [ + "Voice memos", + ], + }); + }); + }); +}); diff --git a/apps/desktop/src/survey/prompt.tsx b/apps/desktop/src/survey/prompt.tsx new file mode 100644 index 0000000000..000a6c5bbe --- /dev/null +++ b/apps/desktop/src/survey/prompt.tsx @@ -0,0 +1,103 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; + +import { + commands as analyticsCommands, + type AnalyticsPayload, +} from "@hypr/plugin-analytics"; + +import { + buildOnboardingSurveyDismissedPayload, + buildOnboardingSurveyShownPayload, + buildOnboardingSurveySubmittedPayload, + type SurveyResponses, +} from "./config"; +import { OnboardingSurveyDialog } from "./dialog"; +import { + finishOnboardingSurvey, + onboardingSurveyQueryKey, + recordOnboardingSurveyLaunch, +} from "./state"; + +import { useMountEffect } from "~/shared/hooks/useMountEffect"; + +let hasRecordedOnboardingSurveyLaunch = false; + +async function trackSurveyEvent(payload: AnalyticsPayload) { + try { + await analyticsCommands.event(payload); + } catch { + return; + } +} + +export function OnboardingSurveyPrompt() { + const queryClient = useQueryClient(); + const [open, setOpen] = useState(false); + + const completeSurveyMutation = useMutation({ + mutationFn: async ( + input: + | { type: "dismiss" } + | { type: "submit"; responses: SurveyResponses }, + ) => { + if (input.type === "dismiss") { + await trackSurveyEvent(buildOnboardingSurveyDismissedPayload()); + } else { + await trackSurveyEvent( + buildOnboardingSurveySubmittedPayload(input.responses), + ); + } + + return finishOnboardingSurvey(); + }, + onSuccess: (state) => { + queryClient.setQueryData(onboardingSurveyQueryKey, state); + setOpen(false); + }, + }); + + useMountEffect(() => { + if (hasRecordedOnboardingSurveyLaunch) { + return; + } + + hasRecordedOnboardingSurveyLaunch = true; + + let cancelled = false; + + void recordOnboardingSurveyLaunch() + .then((state) => { + queryClient.setQueryData(onboardingSurveyQueryKey, state); + + if (!cancelled && state.launchCount >= 2 && !state.done) { + void trackSurveyEvent(buildOnboardingSurveyShownPayload()); + setOpen(true); + } + }) + .catch(() => undefined); + + return () => { + cancelled = true; + }; + }); + + if (!open) { + return null; + } + + return ( + { + if (!nextOpen && !completeSurveyMutation.isPending) { + completeSurveyMutation.mutate({ type: "dismiss" }); + } + }} + onSubmit={(responses) => + completeSurveyMutation.mutate({ type: "submit", responses }) + } + submitting={completeSurveyMutation.isPending} + /> + ); +} diff --git a/apps/desktop/src/survey/state.ts b/apps/desktop/src/survey/state.ts new file mode 100644 index 0000000000..f9d74c07ae --- /dev/null +++ b/apps/desktop/src/survey/state.ts @@ -0,0 +1,73 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { commands, type OnboardingSurveyState } from "~/types/tauri.gen"; + +export const onboardingSurveyQueryKey = ["onboarding-survey"] as const; + +export const defaultOnboardingSurveyState: OnboardingSurveyState = { + launchCount: 0, + done: false, +}; + +function unwrapSurveyState( + result: Awaited>, +) { + if (result.status === "ok") { + return result.data; + } + + throw new Error(result.error); +} + +export async function getOnboardingSurveyState() { + return unwrapSurveyState(await commands.getOnboardingSurveyState()); +} + +export async function recordOnboardingSurveyLaunch() { + const result = await commands.recordOnboardingSurveyLaunch(); + + if (result.status === "ok") { + return result.data; + } + + throw new Error(result.error); +} + +export async function finishOnboardingSurvey() { + const result = await commands.finishOnboardingSurvey(); + + if (result.status === "ok") { + return result.data; + } + + throw new Error(result.error); +} + +export async function resetOnboardingSurvey() { + const result = await commands.resetOnboardingSurvey(); + + if (result.status === "ok") { + return result.data; + } + + throw new Error(result.error); +} + +export function useOnboardingSurveyState() { + return useQuery({ + queryKey: onboardingSurveyQueryKey, + queryFn: getOnboardingSurveyState, + initialData: defaultOnboardingSurveyState, + }); +} + +export function useResetOnboardingSurvey() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: resetOnboardingSurvey, + onSuccess: (state) => { + queryClient.setQueryData(onboardingSurveyQueryKey, state); + }, + }); +} diff --git a/apps/desktop/src/test-setup.ts b/apps/desktop/src/test-setup.ts index 8837fa8655..66ebbea5d3 100644 --- a/apps/desktop/src/test-setup.ts +++ b/apps/desktop/src/test-setup.ts @@ -40,6 +40,22 @@ vi.mock("./types/tauri.gen", () => ({ getCharV1p1Preview: vi .fn() .mockResolvedValue({ status: "ok", data: false }), + getOnboardingSurveyState: vi.fn().mockResolvedValue({ + status: "ok", + data: { launchCount: 0, done: false }, + }), + recordOnboardingSurveyLaunch: vi.fn().mockResolvedValue({ + status: "ok", + data: { launchCount: 1, done: false }, + }), + finishOnboardingSurvey: vi.fn().mockResolvedValue({ + status: "ok", + data: { launchCount: 1, done: true }, + }), + resetOnboardingSurvey: vi.fn().mockResolvedValue({ + status: "ok", + data: { launchCount: 0, done: false }, + }), getPinnedTabs: vi.fn().mockResolvedValue({ status: "ok", data: null }), setPinnedTabs: vi.fn().mockResolvedValue({ status: "ok", data: null }), getRecentlyOpenedSessions: vi diff --git a/apps/desktop/src/types/tauri.gen.ts b/apps/desktop/src/types/tauri.gen.ts index c1ec983e23..6dc9b21d5a 100644 --- a/apps/desktop/src/types/tauri.gen.ts +++ b/apps/desktop/src/types/tauri.gen.ts @@ -92,6 +92,38 @@ async setRecentlyOpenedSessions(v: string) : Promise> { else return { status: "error", error: e as any }; } }, +async getOnboardingSurveyState() : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("get_onboarding_survey_state") }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async recordOnboardingSurveyLaunch() : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("record_onboarding_survey_launch") }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async finishOnboardingSurvey() : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("finish_onboarding_survey") }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async resetOnboardingSurvey() : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("reset_onboarding_survey") }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, async getCharV1p1Preview() : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("get_char_v1p1_preview") }; @@ -146,6 +178,7 @@ async uninstallEmbeddedCli() : Promise> { export type EmbeddedCliState = "installed" | "missing" | "conflict" | "unsupported" | "resource_missing" export type EmbeddedCliStatus = { supported: boolean; commandName: string; installPath: string; resourcePath: string | null; state: EmbeddedCliState; details: string | null } +export type OnboardingSurveyState = { launchCount: number; done: boolean } /** tauri-specta globals **/