Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions apps/desktop/src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use crate::AppExt;
use crate::embedded_cli::EmbeddedCliStatus;
use crate::ext::OnboardingSurveyState;

#[tauri::command]
#[specta::specta]
Expand Down Expand Up @@ -110,6 +111,38 @@ pub async fn set_recently_opened_sessions<R: tauri::Runtime>(
app.set_recently_opened_sessions(v)
}

#[tauri::command]
#[specta::specta]
pub async fn get_onboarding_survey_state<R: tauri::Runtime>(
app: tauri::AppHandle<R>,
) -> Result<OnboardingSurveyState, String> {
app.get_onboarding_survey_state()
}

#[tauri::command]
#[specta::specta]
pub async fn record_onboarding_survey_launch<R: tauri::Runtime>(
app: tauri::AppHandle<R>,
) -> Result<OnboardingSurveyState, String> {
app.record_onboarding_survey_launch()
}

#[tauri::command]
#[specta::specta]
pub async fn finish_onboarding_survey<R: tauri::Runtime>(
app: tauri::AppHandle<R>,
) -> Result<OnboardingSurveyState, String> {
app.finish_onboarding_survey()
}

#[tauri::command]
#[specta::specta]
pub async fn reset_onboarding_survey<R: tauri::Runtime>(
app: tauri::AppHandle<R>,
) -> Result<OnboardingSurveyState, String> {
app.reset_onboarding_survey()
}

#[tauri::command]
#[specta::specta]
pub async fn get_char_v1p1_preview<R: tauri::Runtime>(
Expand Down
55 changes: 55 additions & 0 deletions apps/desktop/src-tauri/src/ext.rs
Original file line number Diff line number Diff line change
@@ -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<R: tauri::Runtime> {
fn desktop_store(&self) -> Result<ScopedStore<R, crate::StoreKey>, String>;

Expand All @@ -18,6 +26,12 @@ pub trait AppExt<R: tauri::Runtime> {
fn get_recently_opened_sessions(&self) -> Result<Option<String>, String>;
fn set_recently_opened_sessions(&self, v: String) -> Result<(), String>;

fn get_onboarding_survey_state(&self) -> Result<OnboardingSurveyState, String>;
fn set_onboarding_survey_state(&self, state: OnboardingSurveyState) -> Result<(), String>;
fn record_onboarding_survey_launch(&self) -> Result<OnboardingSurveyState, String>;
fn finish_onboarding_survey(&self) -> Result<OnboardingSurveyState, String>;
fn reset_onboarding_survey(&self) -> Result<OnboardingSurveyState, String>;

fn get_char_v1p1_preview(&self) -> Result<bool, String>;
fn set_char_v1p1_preview(&self, v: bool) -> Result<(), String>;
}
Expand Down Expand Up @@ -115,6 +129,47 @@ impl<R: tauri::Runtime, T: tauri::Manager<R>> AppExt<R> for T {
store.save().map_err(|e| e.to_string())
}

#[tracing::instrument(skip_all)]
fn get_onboarding_survey_state(&self) -> Result<OnboardingSurveyState, String> {
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<OnboardingSurveyState, String> {
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<OnboardingSurveyState, String> {
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<OnboardingSurveyState, String> {
let state = OnboardingSurveyState::default();
self.set_onboarding_survey_state(state.clone())?;
Ok(state)
}

#[tracing::instrument(skip_all)]
fn get_char_v1p1_preview(&self) -> Result<bool, String> {
if cfg!(feature = "new") {
Expand Down
4 changes: 4 additions & 0 deletions apps/desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,10 @@ fn make_specta_builder<R: tauri::Runtime>() -> tauri_specta::Builder<R> {
commands::set_pinned_tabs::<tauri::Wry>,
commands::get_recently_opened_sessions::<tauri::Wry>,
commands::set_recently_opened_sessions::<tauri::Wry>,
commands::get_onboarding_survey_state::<tauri::Wry>,
commands::record_onboarding_survey_launch::<tauri::Wry>,
commands::finish_onboarding_survey::<tauri::Wry>,
commands::reset_onboarding_survey::<tauri::Wry>,
commands::get_char_v1p1_preview::<tauri::Wry>,
commands::set_char_v1p1_preview::<tauri::Wry>,
commands::check_embedded_cli::<tauri::Wry>,
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src-tauri/src/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ pub enum StoreKey {
OnboardingNeeded2,
DismissedToasts,
OnboardingLocal,
OnboardingSurvey,
TinybaseValues,
PinnedTabs,
RecentlyOpenedSessions,
Expand Down
7 changes: 6 additions & 1 deletion apps/desktop/src/services/event-listeners.test.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -100,7 +101,11 @@ describe("EventListeners notification events", () => {
};
useSettingsStoreMock.mockReturnValue(settingsStore as never);

render(<EventListeners />);
render(
<QueryClientProvider client={new QueryClient()}>
<EventListeners />
</QueryClientProvider>,
);

await vi.waitFor(() =>
expect(notificationListenMock).toHaveBeenCalledTimes(1),
Expand Down
3 changes: 2 additions & 1 deletion apps/desktop/src/services/event-listeners.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down Expand Up @@ -196,5 +197,5 @@ export function EventListeners() {
useUpdaterEvents();
useNotificationEvents();

return null;
return <OnboardingSurveyPrompt />;
}
62 changes: 62 additions & 0 deletions apps/desktop/src/sidebar/devtool.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -17,6 +22,7 @@ export function DevtoolView() {
<div className="flex flex-1 flex-col gap-2 overflow-y-auto px-1 py-2">
<NavigationCard />
<ToastsCard />
<SurveyCard />
<CountdownTestCard />
<ErrorTestCard />
</div>
Expand Down Expand Up @@ -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 (
<DevtoolCard title="Survey">
<div className="flex flex-col gap-1.5">
<div className="text-xs text-neutral-500">
Launch count: {surveyState.launchCount} | Done:{" "}
{surveyState.done ? "yes" : "no"}
</div>
<button
type="button"
onClick={() => void refetch()}
disabled={isFetching}
className={btnClass}
>
Refresh State
</button>
<button
type="button"
onClick={() => resetMutation.mutate()}
disabled={resetMutation.isPending}
className={btnClass}
>
Reset Survey State
</button>
<button
type="button"
onClick={() => setPreviewOpen(true)}
className={btnClass}
>
Preview Survey Modal
</button>
</div>
{previewOpen ? (
<OnboardingSurveyDialog
open={previewOpen}
onOpenChange={setPreviewOpen}
onSubmit={() => setPreviewOpen(false)}
/>
) : null}
</DevtoolCard>
);
}

function ErrorTestCard() {
const [shouldThrow, setShouldThrow] = useState(false);

Expand Down
125 changes: 125 additions & 0 deletions apps/desktop/src/survey/config.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, string[]>>;

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,
};
}
Loading
Loading