mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-06-12 17:57:50 +02:00
feat: add onboarding
This commit is contained in:
+116
-4
@@ -3,6 +3,7 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { getCurrent } from "@tauri-apps/plugin-deep-link";
|
||||
import { useOnborda } from "onborda";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AccountPage } from "@/components/account-page";
|
||||
@@ -23,6 +24,7 @@ import { GroupManagementDialog } from "@/components/group-management-dialog";
|
||||
import HomeHeader from "@/components/home-header";
|
||||
import { ImportProfileDialog } from "@/components/import-profile-dialog";
|
||||
import { IntegrationsDialog } from "@/components/integrations-dialog";
|
||||
import { ONBOARDING_TOUR } from "@/components/onboarding-provider";
|
||||
import { PermissionDialog } from "@/components/permission-dialog";
|
||||
import { ProfilesDataTable } from "@/components/profile-data-table";
|
||||
import {
|
||||
@@ -39,7 +41,9 @@ import { ShortcutsPage } from "@/components/shortcuts-page";
|
||||
import { SyncAllDialog } from "@/components/sync-all-dialog";
|
||||
import { SyncConfigDialog } from "@/components/sync-config-dialog";
|
||||
import { SyncFollowerDialog } from "@/components/sync-follower-dialog";
|
||||
import { ThankYouDialog } from "@/components/thank-you-dialog";
|
||||
import { WayfernTermsDialog } from "@/components/wayfern-terms-dialog";
|
||||
import { WelcomeDialog } from "@/components/welcome-dialog";
|
||||
import { WindowResizeWarningDialog } from "@/components/window-resize-warning-dialog";
|
||||
import { useAppUpdateNotifications } from "@/hooks/use-app-update-notifications";
|
||||
import { useCloudAuth } from "@/hooks/use-cloud-auth";
|
||||
@@ -55,6 +59,10 @@ import { useVersionUpdater } from "@/hooks/use-version-updater";
|
||||
import { useVpnEvents } from "@/hooks/use-vpn-events";
|
||||
import { useWayfernTerms } from "@/hooks/use-wayfern-terms";
|
||||
import { translateBackendError } from "@/lib/backend-errors";
|
||||
import {
|
||||
ONBOARDING_TOUR_FINISHED_EVENT,
|
||||
setOnboardingActive,
|
||||
} from "@/lib/onboarding-signal";
|
||||
import {
|
||||
matchesGroupDigit,
|
||||
matchesShortcut,
|
||||
@@ -95,6 +103,95 @@ export default function Home() {
|
||||
error: profilesError,
|
||||
} = useProfileEvents();
|
||||
|
||||
// First-run onboarding tour (Onborda).
|
||||
const { startOnborda, setCurrentStep, isOnbordaVisible, currentStep } =
|
||||
useOnborda();
|
||||
const onboardingHandledRef = useRef(false);
|
||||
const [welcomeOpen, setWelcomeOpen] = useState(false);
|
||||
const [thankYouOpen, setThankYouOpen] = useState(false);
|
||||
// null = onboarding decision pending; false = not a first-run onboarding (run
|
||||
// the normal permission checks); true = first-run onboarding, so the welcome
|
||||
// flow drives permissions and the standalone permission dialog is suppressed.
|
||||
const [firstRunOnboarding, setFirstRunOnboarding] = useState<boolean | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
// Welcome flow finished. Existing-profile users are done after the welcome +
|
||||
// commercial-use steps; users with no profile yet continue into the in-app
|
||||
// product tour that walks them through creating their first profile.
|
||||
const handleWelcomeComplete = useCallback(() => {
|
||||
setWelcomeOpen(false);
|
||||
setFirstRunOnboarding(false);
|
||||
if (profiles.length === 0) {
|
||||
startOnborda(ONBOARDING_TOUR);
|
||||
}
|
||||
}, [startOnborda, profiles.length]);
|
||||
|
||||
// The product tour finished (user clicked "Finish", not "Skip") → celebrate.
|
||||
useEffect(() => {
|
||||
const handler = () => setThankYouOpen(true);
|
||||
window.addEventListener(ONBOARDING_TOUR_FINISHED_EVENT, handler);
|
||||
return () =>
|
||||
window.removeEventListener(ONBOARDING_TOUR_FINISHED_EVENT, handler);
|
||||
}, []);
|
||||
|
||||
// Suppress the global browser-download toasts while onboarding (welcome or
|
||||
// tour) is active — the welcome dialog shows setup progress itself.
|
||||
useEffect(() => {
|
||||
setOnboardingActive(welcomeOpen || isOnbordaVisible);
|
||||
}, [welcomeOpen, isOnbordaVisible]);
|
||||
|
||||
// While the tour is visible, keep the body pinned to the left. Onborda calls
|
||||
// scrollIntoView({ inline: "center" }) on the highlighted element; because the
|
||||
// body is overflow-hidden it can still be scrolled programmatically, which
|
||||
// would shove the whole app (rail and all) sideways with no way to scroll
|
||||
// back. The profile table keeps its own scroll container, untouched here.
|
||||
useEffect(() => {
|
||||
if (!isOnbordaVisible) return;
|
||||
const pin = () => {
|
||||
if (document.body.scrollLeft !== 0) document.body.scrollLeft = 0;
|
||||
if (document.documentElement.scrollLeft !== 0)
|
||||
document.documentElement.scrollLeft = 0;
|
||||
};
|
||||
pin();
|
||||
window.addEventListener("scroll", pin, true);
|
||||
return () => window.removeEventListener("scroll", pin, true);
|
||||
}, [isOnbordaVisible]);
|
||||
|
||||
// On the very first launch, always show the welcome + commercial-use steps
|
||||
// (one-shot: the backend flag is set immediately so it can't trigger again).
|
||||
// The welcome dialog itself decides whether to continue into the browser
|
||||
// download + profile-creation flow — only when the user has no profile yet.
|
||||
useEffect(() => {
|
||||
if (profilesLoading || onboardingHandledRef.current) return;
|
||||
onboardingHandledRef.current = true;
|
||||
void (async () => {
|
||||
try {
|
||||
const completed = await invoke<boolean>("get_onboarding_completed");
|
||||
if (completed) {
|
||||
setFirstRunOnboarding(false);
|
||||
return;
|
||||
}
|
||||
await invoke("complete_onboarding");
|
||||
setFirstRunOnboarding(true);
|
||||
setWelcomeOpen(true);
|
||||
} catch (err) {
|
||||
console.error("Onboarding init failed:", err);
|
||||
setFirstRunOnboarding(false);
|
||||
}
|
||||
})();
|
||||
}, [profilesLoading]);
|
||||
|
||||
// Advance from the "create a profile" step to the "DNS blocking" step as soon
|
||||
// as the user's first profile exists (its DNS dropdown is now in the DOM).
|
||||
useEffect(() => {
|
||||
if (isOnbordaVisible && currentStep === 0 && profiles.length > 0) {
|
||||
// Small delay so the new profile row (and its DNS dropdown target) has
|
||||
// mounted before Onborda re-points at it.
|
||||
setCurrentStep(1, 300);
|
||||
}
|
||||
}, [isOnbordaVisible, currentStep, profiles.length, setCurrentStep]);
|
||||
|
||||
const {
|
||||
groups: groupsData,
|
||||
isLoading: groupsLoading,
|
||||
@@ -775,9 +872,12 @@ export default function Home() {
|
||||
} catch (error) {
|
||||
showErrorToast(
|
||||
t("errors.createProfileFailed", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
error: translateBackendError(t, error),
|
||||
}),
|
||||
);
|
||||
// Rethrow so the create dialog keeps itself open (its own handler
|
||||
// skips closing on error), letting the user fix the proxy/VPN and retry.
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[selectedGroupId, t],
|
||||
@@ -1349,12 +1449,14 @@ export default function Home() {
|
||||
};
|
||||
}, [checkTerms]);
|
||||
|
||||
// Check permissions when they are initialized
|
||||
// Check permissions when they are initialized. During first-run onboarding
|
||||
// the welcome flow requests permissions, so the standalone dialog is deferred
|
||||
// until we know this isn't a first-run onboarding.
|
||||
useEffect(() => {
|
||||
if (isInitialized) {
|
||||
if (isInitialized && firstRunOnboarding === false) {
|
||||
checkAllPermissions();
|
||||
}
|
||||
}, [isInitialized, checkAllPermissions]);
|
||||
}, [isInitialized, firstRunOnboarding, checkAllPermissions]);
|
||||
|
||||
// Check self-hosted sync config on mount and when cloud user changes
|
||||
useEffect(() => {
|
||||
@@ -1624,6 +1726,16 @@ export default function Home() {
|
||||
onPermissionGranted={checkNextPermission}
|
||||
/>
|
||||
|
||||
<WelcomeDialog
|
||||
isOpen={welcomeOpen}
|
||||
needsSetup={profiles.length === 0}
|
||||
onComplete={handleWelcomeComplete}
|
||||
/>
|
||||
<ThankYouDialog
|
||||
isOpen={thankYouOpen}
|
||||
onClose={() => setThankYouOpen(false)}
|
||||
/>
|
||||
|
||||
<CloneProfileDialog
|
||||
isOpen={!!cloneProfile}
|
||||
onClose={() => {
|
||||
|
||||
Reference in New Issue
Block a user