hard-cut onboarding modal flow orchestration

This commit is contained in:
Richard R 2026-05-27 00:40:12 -06:00
parent 14b1b5602f
commit 22f132bbc8
7 changed files with 371 additions and 346 deletions

View file

@ -4,16 +4,13 @@ import type { ReactNode } from 'react';
import { ConfigProvider } from '@/contexts/ConfigContext';
import { DocumentProvider } from '@/contexts/DocumentContext';
import { DexieMigrationModal } from '@/components/documents/DexieMigrationModal';
import { OnboardingFlowProvider } from '@/contexts/OnboardingFlowContext';
export default function AppHomeLayout({ children }: { children: ReactNode }) {
return (
<ConfigProvider>
<DocumentProvider>
<>
{children}
<DexieMigrationModal />
</>
<OnboardingFlowProvider>{children}</OnboardingFlowProvider>
</DocumentProvider>
</ConfigProvider>
);

View file

@ -3,7 +3,6 @@ import type { ReactNode } from 'react';
import { Toaster } from 'react-hot-toast';
import { Providers } from '@/app/providers';
import ClaimDataPopup from '@/components/auth/ClaimDataModal';
import { getAuthBaseUrl, isAnonymousAuthSessionsEnabled, isAuthEnabled, isGithubAuthEnabled } from '@/lib/server/auth/config';
export const dynamic = 'force-dynamic';
@ -36,7 +35,6 @@ export default function AppLayout({ children }: { children: ReactNode }) {
githubAuthEnabled={githubAuthEnabled}
>
<div className="app-shell min-h-screen flex flex-col bg-background">
{authEnabled && <ClaimDataPopup />}
<main className="flex-1 flex flex-col">{children}</main>
</div>
<Toaster

View file

@ -18,7 +18,6 @@ import Link from 'next/link';
import { useTheme } from '@/contexts/ThemeContext';
import { useConfig } from '@/contexts/ConfigContext';
import { ChevronUpDownIcon, CheckIcon, SettingsIcon, KeyIcon, PaletteIcon, DocumentIcon, UserIcon, DownloadIcon, ChevronRightIcon } from '@/components/icons/Icons';
import { getAppConfig, getFirstVisit, setFirstVisit } from '@/lib/client/dexie';
import { useDocuments } from '@/contexts/DocumentContext';
import { ConfirmDialog } from '@/components/ConfirmDialog';
import { ProgressPopup } from '@/components/ProgressPopup';
@ -33,7 +32,6 @@ import { useAuthConfig } from '@/contexts/AuthRateLimitContext';
import { useRouter } from 'next/navigation';
import { showPrivacyModal } from '@/components/PrivacyModal';
import { deleteDocuments, mimeTypeForDoc, uploadDocuments } from '@/lib/client/api/documents';
import { postChangelogVersionCheck } from '@/lib/client/api/user-state';
import { cacheStoredDocumentFromBytes, clearDocumentCache } from '@/lib/client/cache/documents';
import { clearAllDocumentPreviewCaches, clearInMemoryDocumentPreviewCache } from '@/lib/client/cache/previews';
import { resolveTtsSettingsViewModel } from '@/lib/client/settings/tts-settings';
@ -71,8 +69,7 @@ import {
type ChangelogManifestEntry,
type ChangelogReleaseBody,
} from '@/lib/shared/changelog';
import { useOnboardingCoordinator } from '@/hooks/useOnboardingCoordinator';
import { ONBOARDING_STATE_REGISTRY } from '@/lib/shared/onboarding-state';
import { useOnboardingFlow } from '@/contexts/OnboardingFlowContext';
// Hard-coded theme color palettes for the visual theme selector
type ThemeColorSet = { background: string; base: string; offbase: string; accent: string; secondaryAccent: string; foreground: string; muted: string };
@ -174,7 +171,8 @@ export function SettingsModal({ className = '' }: { className?: string }) {
const [showDeleteAccountConfirm, setShowDeleteAccountConfirm] = useState(false);
const { progress, setProgress, estimatedTimeRemaining } = useTimeEstimation();
const { authEnabled, baseUrl: authBaseUrl } = useAuthConfig();
const { data: session, isPending: isSessionPending } = useAuthSession();
const { data: session } = useAuthSession();
const { requestOpenSettings, registerSettingsController } = useOnboardingFlow();
const router = useRouter();
const isBusy = isImportingLibrary;
const {
@ -217,34 +215,15 @@ export function SettingsModal({ className = '' }: { className?: string }) {
setIsChangelogOpen(Boolean(options?.changelog));
}, []);
const readLocalOnboardingSnapshot = useCallback(async () => {
const appConfig = await getAppConfig();
const row = appConfig as Record<string, unknown> | null;
const privacyKey = ONBOARDING_STATE_REGISTRY.privacyAccepted.localKey;
const firstVisitKey = ONBOARDING_STATE_REGISTRY.firstVisitSettingsOpened.localKey;
return {
privacyAccepted: privacyKey ? Boolean(row?.[privacyKey]) : false,
firstVisitSettingsOpened: firstVisitKey ? Boolean(row?.[firstVisitKey]) : await getFirstVisit(),
useEffect(() => {
registerSettingsController({
open: openSettings,
close: closeSettings,
});
return () => {
registerSettingsController(null);
};
}, []);
const markFirstVisitSettingsOpened = useCallback(async () => {
await setFirstVisit(true);
}, []);
const { requestOpenSettings } = useOnboardingCoordinator({
authEnabled,
isSessionPending,
sessionUserId: session?.user?.id,
appVersion: runtimeConfig.appVersion,
isSettingsOpen: isOpen,
readLocalSnapshot: readLocalOnboardingSnapshot,
markFirstVisitSettingsOpened,
postChangelogVersionCheck: async (currentVersion) => postChangelogVersionCheck(currentVersion),
openSettings,
closeSettings,
});
}, [closeSettings, openSettings, registerSettingsController]);
useEffect(() => {
setLocalApiKey(apiKey);
@ -530,7 +509,11 @@ export function SettingsModal({ className = '' }: { className?: string }) {
</Button>
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-50" onClose={resetToCurrent}>
<Dialog
as="div"
className={`relative ${isChangelogOpen ? 'z-[90]' : 'z-50'}`}
onClose={resetToCurrent}
>
<TransitionChild
as={Fragment}
enter="ease-out duration-300"

View file

@ -1,6 +1,6 @@
'use client';
import { Fragment, useState, useEffect, useCallback } from 'react';
import { Fragment, useState } from 'react';
import {
Dialog,
DialogPanel,
@ -9,11 +9,10 @@ import {
TransitionChild,
Button,
} from '@headlessui/react';
import { useAuthSession } from '@/hooks/useAuthSession';
import { useRouter } from 'next/navigation';
import toast from 'react-hot-toast';
type ClaimableCounts = {
export type ClaimableCounts = {
documents: number;
audiobooks: number;
preferences: number;
@ -30,53 +29,21 @@ function toClaimableCounts(value: unknown): ClaimableCounts {
};
}
export default function ClaimDataModal() {
const { data: sessionData } = useAuthSession();
type ClaimDataModalProps = {
isOpen: boolean;
claimableCounts: ClaimableCounts;
onDismiss: () => void;
onClaimed: () => void;
};
export default function ClaimDataModal({
isOpen,
claimableCounts,
onDismiss,
onClaimed,
}: ClaimDataModalProps) {
const router = useRouter();
const [isOpen, setIsOpen] = useState(false);
const [hasChecked, setHasChecked] = useState(false);
const [isClaiming, setIsClaiming] = useState(false);
const [claimableCounts, setClaimableCounts] = useState<ClaimableCounts>({
documents: 0,
audiobooks: 0,
preferences: 0,
progress: 0,
});
const user = sessionData?.user;
const userId = user?.id;
const checkClaimableData = useCallback(async () => {
setHasChecked(true);
try {
const res = await fetch('/api/user/claim', {
method: 'GET',
});
if (res.ok) {
const data = await res.json();
const counts = toClaimableCounts(data);
setClaimableCounts(counts);
if (counts.documents + counts.audiobooks + counts.preferences + counts.progress > 0) {
setIsOpen(true);
}
}
} catch (e) {
console.error(e);
}
}, []);
useEffect(() => {
// Reset per-user guard so account switches trigger a fresh check.
setHasChecked(false);
}, [userId]);
useEffect(() => {
// Only check once per authenticated user
if (userId && !user?.isAnonymous && !hasChecked) {
checkClaimableData();
}
}, [userId, user?.isAnonymous, hasChecked, checkClaimableData]);
const handleClaim = async () => {
setIsClaiming(true);
@ -93,8 +60,7 @@ export default function ClaimDataModal() {
+ `${claimed.preferences} preference set(s), and `
+ `${claimed.progress} reading progress record(s)!`,
);
setIsOpen(false);
onClaimed();
router.refresh();
return;
}
@ -107,15 +73,9 @@ export default function ClaimDataModal() {
}
};
const handleDismiss = () => {
// Close the modal for this session - will reappear on next page load/refresh
setIsOpen(false);
// Keep hasChecked = true so useEffect doesn't re-trigger in this session
};
return (
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-50" onClose={handleDismiss}>
<Dialog as="div" className="relative z-[80]" onClose={onDismiss}>
<TransitionChild
as={Fragment}
enter="ease-out duration-300"
@ -169,7 +129,7 @@ export default function ClaimDataModal() {
<div className="flex justify-end gap-3">
<Button
type="button"
onClick={handleDismiss}
onClick={onDismiss}
disabled={isClaiming}
className="inline-flex justify-center rounded-lg bg-background px-3 py-1.5 text-sm
font-medium text-foreground hover:bg-offbase focus:outline-none

View file

@ -1,25 +1,38 @@
'use client';
import { Fragment, useCallback, useEffect, useRef, useState } from 'react';
import { Fragment, useCallback, useEffect, useState } from 'react';
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild, Button } from '@headlessui/react';
import { getAppConfig, getAllEpubDocuments, getAllHtmlDocuments, getAllPdfDocuments, updateAppConfig } from '@/lib/client/dexie';
import { getAllEpubDocuments, getAllHtmlDocuments, getAllPdfDocuments, updateAppConfig } from '@/lib/client/dexie';
import { listDocuments, mimeTypeForDoc, uploadDocuments } from '@/lib/client/api/documents';
import { useDocuments } from '@/contexts/DocumentContext';
import type { BaseDocument } from '@/types/documents';
import { cacheStoredDocumentFromBytes } from '@/lib/client/cache/documents';
export function DexieMigrationModal() {
type DexieMigrationModalProps = {
isOpen: boolean;
localCount: number;
missingCount: number;
onComplete: () => void;
};
export function DexieMigrationModal({
isOpen,
localCount,
missingCount,
onComplete,
}: DexieMigrationModalProps) {
const { refreshDocuments } = useDocuments();
const [isOpen, setIsOpen] = useState(false);
const [localCount, setLocalCount] = useState(0);
const [missingCount, setMissingCount] = useState(0);
const [displayMissingCount, setDisplayMissingCount] = useState(missingCount);
const [isUploading, setIsUploading] = useState(false);
const [progress, setProgress] = useState(0);
const [status, setStatus] = useState<string>('');
const checkedRef = useRef(false);
const closeDisabled = isUploading;
useEffect(() => {
setDisplayMissingCount(missingCount);
}, [missingCount, isOpen]);
const loadLocalDexieDocs = useCallback(async (): Promise<{
docs: BaseDocument[];
pdfById: Map<string, { id: string; name: string; size: number; lastModified: number; data: ArrayBuffer }>;
@ -38,60 +51,12 @@ export function DexieMigrationModal() {
return { docs, pdfById, epubById, htmlById };
}, []);
const checkAndMaybePrompt = useCallback(async () => {
if (checkedRef.current) return;
checkedRef.current = true;
const cfg = await getAppConfig();
if (!cfg?.privacyAccepted) {
// Wait for privacy acceptance before prompting.
checkedRef.current = false;
return;
}
if (cfg.documentsMigrationPrompted) return;
const { docs } = await loadLocalDexieDocs();
const count = docs.length;
setLocalCount(count);
if (count === 0) return;
const serverDocs = await listDocuments().catch(() => null);
if (serverDocs) {
const serverIds = new Set(serverDocs.map((d) => d.id));
const missing = docs.filter((d) => !serverIds.has(d.id));
setMissingCount(missing.length);
if (missing.length === 0) return;
} else {
// If the server list fails, still prompt so the user can attempt upload.
setMissingCount(count);
}
setIsOpen(true);
}, [loadLocalDexieDocs]);
useEffect(() => {
checkAndMaybePrompt().catch((err) => {
console.error('Dexie migration check failed:', err);
});
}, [checkAndMaybePrompt]);
useEffect(() => {
const handler = () => {
checkedRef.current = false;
checkAndMaybePrompt().catch((err) => console.error('Dexie migration check failed:', err));
};
window.addEventListener('openreader:privacyAccepted', handler as EventListener);
return () => window.removeEventListener('openreader:privacyAccepted', handler as EventListener);
}, [checkAndMaybePrompt]);
const title = 'Upload your local documents?';
const handleSkip = useCallback(async () => {
await updateAppConfig({ documentsMigrationPrompted: true });
setIsOpen(false);
}, []);
onComplete();
}, [onComplete]);
const handleUpload = useCallback(async () => {
setIsUploading(true);
@ -104,7 +69,7 @@ export function DexieMigrationModal() {
const serverDocs = await listDocuments().catch(() => null);
const serverIds = serverDocs ? new Set(serverDocs.map((d) => d.id)) : null;
const toUpload = serverIds ? docs.filter((d) => !serverIds.has(d.id)) : docs;
setMissingCount(toUpload.length);
setDisplayMissingCount(toUpload.length);
const encoder = new TextEncoder();
for (let i = 0; i < toUpload.length; i++) {
@ -159,21 +124,20 @@ export function DexieMigrationModal() {
setStatus('Refreshing...');
await refreshDocuments();
await updateAppConfig({ documentsMigrationPrompted: true });
setIsOpen(false);
onComplete();
} catch (err) {
console.error('Dexie migration upload failed:', err);
setStatus('Upload failed. You can retry or skip.');
checkedRef.current = false;
} finally {
setIsUploading(false);
}
}, [loadLocalDexieDocs, refreshDocuments]);
}, [loadLocalDexieDocs, onComplete, refreshDocuments]);
if (!isOpen) return null;
return (
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-[70]" onClose={() => (closeDisabled ? null : setIsOpen(false))}>
<Dialog as="div" className="relative z-[80]" onClose={() => (closeDisabled ? null : onComplete())}>
<TransitionChild
as={Fragment}
enter="ease-out duration-300"
@ -204,8 +168,8 @@ export function DexieMigrationModal() {
<div className="space-y-2">
<p className="text-sm text-muted mb-2">
Found {localCount} document{localCount === 1 ? '' : 's'} stored locally from an older version.
{missingCount > 0 ? (
<> {missingCount} {missingCount === 1 ? 'is' : 'are'} not here yet.</>
{displayMissingCount > 0 ? (
<> {displayMissingCount} {displayMissingCount === 1 ? 'is' : 'are'} not here yet.</>
) : null}
{' '}This app now stores documents on the server and keeps a local cache for speed.
</p>

View file

@ -0,0 +1,307 @@
'use client';
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import ClaimDataModal, { type ClaimableCounts } from '@/components/auth/ClaimDataModal';
import { DexieMigrationModal } from '@/components/documents/DexieMigrationModal';
import { useAuthConfig } from '@/contexts/AuthRateLimitContext';
import { useAuthSession } from '@/hooks/useAuthSession';
import { useRuntimeConfig } from '@/contexts/RuntimeConfigContext';
import { getAllEpubDocuments, getAllHtmlDocuments, getAllPdfDocuments, getAppConfig, setFirstVisit } from '@/lib/client/dexie';
import { listDocuments } from '@/lib/client/api/documents';
import { postChangelogVersionCheck } from '@/lib/client/api/user-state';
import { scheduleChangelogCheck } from '@/lib/client/changelog-check';
import { ONBOARDING_STATE_REGISTRY } from '@/lib/shared/onboarding-state';
type SettingsOpenOptions = {
changelog?: boolean;
};
type SettingsController = {
open: (options?: SettingsOpenOptions) => void;
close: () => void;
};
type OnboardingFlowContextValue = {
requestOpenSettings: (options?: SettingsOpenOptions) => Promise<boolean>;
registerSettingsController: (controller: SettingsController | null) => void;
};
const OnboardingFlowContext = createContext<OnboardingFlowContextValue | null>(null);
type MigrationPromptState = {
shouldPrompt: boolean;
localCount: number;
missingCount: number;
};
const EMPTY_CLAIM_COUNTS: ClaimableCounts = {
documents: 0,
audiobooks: 0,
preferences: 0,
progress: 0,
};
function toClaimableCounts(value: unknown): ClaimableCounts {
const rec = (value && typeof value === 'object') ? (value as Record<string, unknown>) : {};
return {
documents: Number(rec.documents ?? 0),
audiobooks: Number(rec.audiobooks ?? 0),
preferences: Number(rec.preferences ?? 0),
progress: Number(rec.progress ?? 0),
};
}
async function fetchClaimableCounts(): Promise<ClaimableCounts> {
const res = await fetch('/api/user/claim', { method: 'GET' });
if (!res.ok) {
return EMPTY_CLAIM_COUNTS;
}
const data = await res.json();
return toClaimableCounts(data);
}
async function getMigrationPromptState(): Promise<MigrationPromptState> {
const cfg = await getAppConfig();
if (!cfg?.privacyAccepted || cfg.documentsMigrationPrompted) {
return { shouldPrompt: false, localCount: 0, missingCount: 0 };
}
const [pdfs, epubs, htmls] = await Promise.all([
getAllPdfDocuments(),
getAllEpubDocuments(),
getAllHtmlDocuments(),
]);
const localDocs = [
...pdfs.map((d) => d.id),
...epubs.map((d) => d.id),
...htmls.map((d) => d.id),
];
const localCount = localDocs.length;
if (localCount === 0) {
return { shouldPrompt: false, localCount: 0, missingCount: 0 };
}
const serverDocs = await listDocuments().catch(() => null);
if (!serverDocs) {
return { shouldPrompt: true, localCount, missingCount: localCount };
}
const serverIds = new Set(serverDocs.map((d) => d.id));
const missingCount = localDocs.filter((id) => !serverIds.has(id)).length;
return {
shouldPrompt: missingCount > 0,
localCount,
missingCount,
};
}
type LocalOnboardingSnapshot = {
privacyAccepted: boolean;
firstVisitSettingsOpened: boolean;
};
async function readLocalOnboardingSnapshot(): Promise<LocalOnboardingSnapshot> {
const appConfig = await getAppConfig();
const row = appConfig as Record<string, unknown> | null;
const privacyKey = ONBOARDING_STATE_REGISTRY.privacyAccepted.localKey;
const firstVisitKey = ONBOARDING_STATE_REGISTRY.firstVisitSettingsOpened.localKey;
return {
privacyAccepted: privacyKey ? Boolean(row?.[privacyKey]) : false,
firstVisitSettingsOpened: firstVisitKey ? Boolean(row?.[firstVisitKey]) : false,
};
}
export function OnboardingFlowProvider({ children }: { children: ReactNode }) {
const { authEnabled } = useAuthConfig();
const { data: session, isPending: isSessionPending } = useAuthSession();
const runtimeConfig = useRuntimeConfig();
const user = session?.user as { id?: string; isAnonymous?: boolean } | undefined;
const userId = user?.id ?? null;
const isAnonymous = Boolean(user?.isAnonymous);
const [activeBlockingModal, setActiveBlockingModal] = useState<'claim' | 'migration' | null>(null);
const [claimableCounts, setClaimableCounts] = useState<ClaimableCounts>(EMPTY_CLAIM_COUNTS);
const [migrationCounts, setMigrationCounts] = useState<{ localCount: number; missingCount: number }>({
localCount: 0,
missingCount: 0,
});
const settingsControllerRef = useRef<SettingsController | null>(null);
const pendingChangelogOpenRef = useRef(false);
const runningAdvanceRef = useRef(false);
const claimDismissedUsersRef = useRef<Set<string>>(new Set());
const changelogVersionCheckKeyRef = useRef<string | null>(null);
const changelogVersionCheckInFlightRef = useRef<string | null>(null);
const openSettingsNow = useCallback((options?: SettingsOpenOptions) => {
settingsControllerRef.current?.open(options);
}, []);
const advanceFlow = useCallback(async () => {
if (runningAdvanceRef.current) {
return;
}
runningAdvanceRef.current = true;
try {
if (activeBlockingModal) {
return;
}
const local = await readLocalOnboardingSnapshot();
if (authEnabled && !local.privacyAccepted) {
return;
}
if (authEnabled && userId && !isAnonymous && !claimDismissedUsersRef.current.has(userId)) {
const counts = await fetchClaimableCounts();
const total = counts.documents + counts.audiobooks + counts.preferences + counts.progress;
if (total > 0) {
setClaimableCounts(counts);
setActiveBlockingModal('claim');
return;
}
claimDismissedUsersRef.current.add(userId);
}
const migrationState = await getMigrationPromptState();
if (migrationState.shouldPrompt) {
setMigrationCounts({
localCount: migrationState.localCount,
missingCount: migrationState.missingCount,
});
setActiveBlockingModal('migration');
return;
}
if (!local.firstVisitSettingsOpened) {
await setFirstVisit(true);
openSettingsNow();
return;
}
if (pendingChangelogOpenRef.current) {
pendingChangelogOpenRef.current = false;
openSettingsNow({ changelog: true });
}
} finally {
runningAdvanceRef.current = false;
}
}, [activeBlockingModal, authEnabled, isAnonymous, openSettingsNow, userId]);
const requestOpenSettings = useCallback(async (options?: SettingsOpenOptions): Promise<boolean> => {
const local = await readLocalOnboardingSnapshot();
if (authEnabled && !local.privacyAccepted) {
if (options?.changelog) {
pendingChangelogOpenRef.current = true;
}
settingsControllerRef.current?.close();
return false;
}
if (activeBlockingModal) {
if (options?.changelog) {
pendingChangelogOpenRef.current = true;
}
return false;
}
if (!settingsControllerRef.current) {
if (options?.changelog) {
pendingChangelogOpenRef.current = true;
}
return false;
}
settingsControllerRef.current.open(options);
return true;
}, [activeBlockingModal, authEnabled]);
const registerSettingsController = useCallback((controller: SettingsController | null) => {
settingsControllerRef.current = controller;
if (controller) {
void advanceFlow();
}
}, [advanceFlow]);
const handleClaimComplete = useCallback(() => {
if (userId) {
claimDismissedUsersRef.current.add(userId);
}
setActiveBlockingModal(null);
void advanceFlow();
}, [advanceFlow, userId]);
const handleMigrationComplete = useCallback(() => {
setActiveBlockingModal(null);
void advanceFlow();
}, [advanceFlow]);
useEffect(() => {
void advanceFlow();
}, [advanceFlow, authEnabled, isAnonymous, userId]);
useEffect(() => {
if (!authEnabled) {
return;
}
const onPrivacyAccepted = () => {
void advanceFlow();
};
window.addEventListener('openreader:privacyAccepted', onPrivacyAccepted);
return () => {
window.removeEventListener('openreader:privacyAccepted', onPrivacyAccepted);
};
}, [advanceFlow, authEnabled]);
useEffect(() => {
return scheduleChangelogCheck({
authEnabled,
isSessionPending,
sessionUserId: userId,
appVersion: runtimeConfig.appVersion,
completedRef: changelogVersionCheckKeyRef,
inFlightRef: changelogVersionCheckInFlightRef,
postCheck: async (currentVersion) => postChangelogVersionCheck(currentVersion),
onShouldOpen: () => {
pendingChangelogOpenRef.current = true;
void advanceFlow();
},
delayMs: 120,
retryDelayMs: 400,
});
}, [advanceFlow, authEnabled, isSessionPending, runtimeConfig.appVersion, userId]);
const contextValue = useMemo<OnboardingFlowContextValue>(() => ({
requestOpenSettings,
registerSettingsController,
}), [registerSettingsController, requestOpenSettings]);
return (
<OnboardingFlowContext.Provider value={contextValue}>
{children}
{authEnabled && (
<ClaimDataModal
isOpen={activeBlockingModal === 'claim'}
claimableCounts={claimableCounts}
onDismiss={handleClaimComplete}
onClaimed={handleClaimComplete}
/>
)}
<DexieMigrationModal
isOpen={activeBlockingModal === 'migration'}
localCount={migrationCounts.localCount}
missingCount={migrationCounts.missingCount}
onComplete={handleMigrationComplete}
/>
</OnboardingFlowContext.Provider>
);
}
export function useOnboardingFlow() {
const context = useContext(OnboardingFlowContext);
if (!context) {
throw new Error('useOnboardingFlow must be used inside OnboardingFlowProvider');
}
return context;
}

View file

@ -1,184 +0,0 @@
'use client';
import { useCallback, useEffect, useRef } from 'react';
import {
scheduleChangelogCheck,
type ChangelogVersionCheckResponse,
} from '@/lib/client/changelog-check';
type OpenSettingsOptions = {
changelog?: boolean;
};
type LocalOnboardingSnapshot = {
privacyAccepted: boolean;
firstVisitSettingsOpened: boolean;
};
type UseOnboardingCoordinatorArgs = {
authEnabled: boolean;
isSessionPending: boolean;
sessionUserId: string | null | undefined;
appVersion: string | null | undefined;
isSettingsOpen: boolean;
readLocalSnapshot: () => Promise<LocalOnboardingSnapshot>;
markFirstVisitSettingsOpened: () => Promise<void>;
postChangelogVersionCheck: (currentVersion: string) => Promise<ChangelogVersionCheckResponse>;
openSettings: (options?: OpenSettingsOptions) => void;
closeSettings: () => void;
};
type UseOnboardingCoordinatorResult = {
requestOpenSettings: (options?: OpenSettingsOptions) => Promise<boolean>;
};
export function useOnboardingCoordinator(
args: UseOnboardingCoordinatorArgs,
): UseOnboardingCoordinatorResult {
const {
authEnabled,
isSessionPending,
sessionUserId,
appVersion,
isSettingsOpen,
readLocalSnapshot,
markFirstVisitSettingsOpened,
postChangelogVersionCheck,
openSettings,
closeSettings,
} = args;
const pendingChangelogOpenRef = useRef(false);
const changelogVersionCheckKeyRef = useRef<string | null>(null);
const changelogVersionCheckInFlightRef = useRef<string | null>(null);
const canOpenSettingsNow = useCallback(async (): Promise<{
allowed: boolean;
local: LocalOnboardingSnapshot | null;
}> => {
if (!authEnabled) {
return { allowed: true, local: null };
}
const local = await readLocalSnapshot();
return {
allowed: local.privacyAccepted,
local,
};
}, [authEnabled, readLocalSnapshot]);
const requestOpenSettings = useCallback(async (options?: OpenSettingsOptions): Promise<boolean> => {
const gate = await canOpenSettingsNow();
if (!gate.allowed) {
if (options?.changelog) {
pendingChangelogOpenRef.current = true;
}
closeSettings();
return false;
}
openSettings({ changelog: Boolean(options?.changelog) });
return true;
}, [canOpenSettingsNow, closeSettings, openSettings]);
const maybeOpenFirstVisitSettings = useCallback(async () => {
const gate = await canOpenSettingsNow();
if (!gate.allowed) {
return;
}
const firstVisitSettingsOpened = gate.local
? gate.local.firstVisitSettingsOpened
: (await readLocalSnapshot()).firstVisitSettingsOpened;
if (firstVisitSettingsOpened) {
return;
}
await markFirstVisitSettingsOpened();
openSettings();
}, [canOpenSettingsNow, markFirstVisitSettingsOpened, openSettings, readLocalSnapshot]);
const replayDeferredChangelogOpen = useCallback(async () => {
if (!pendingChangelogOpenRef.current) {
return;
}
const opened = await requestOpenSettings({ changelog: true });
if (opened) {
pendingChangelogOpenRef.current = false;
}
}, [requestOpenSettings]);
useEffect(() => {
maybeOpenFirstVisitSettings().catch((err) => {
console.error('First visit settings check failed:', err);
});
}, [maybeOpenFirstVisitSettings]);
useEffect(() => {
if (!authEnabled) {
return;
}
const onPrivacyAccepted = () => {
maybeOpenFirstVisitSettings().catch((err) => {
console.error('First visit settings check after privacy acceptance failed:', err);
});
replayDeferredChangelogOpen().catch((err) => {
console.error('Deferred changelog open after privacy acceptance failed:', err);
});
};
window.addEventListener('openreader:privacyAccepted', onPrivacyAccepted);
return () => {
window.removeEventListener('openreader:privacyAccepted', onPrivacyAccepted);
};
}, [authEnabled, maybeOpenFirstVisitSettings, replayDeferredChangelogOpen]);
useEffect(() => {
if (!isSettingsOpen) {
return;
}
let cancelled = false;
void (async () => {
const gate = await canOpenSettingsNow();
if (!gate.allowed && !cancelled) {
closeSettings();
}
})();
return () => {
cancelled = true;
};
}, [canOpenSettingsNow, closeSettings, isSettingsOpen]);
useEffect(() => {
return scheduleChangelogCheck({
authEnabled,
isSessionPending,
sessionUserId,
appVersion,
completedRef: changelogVersionCheckKeyRef,
inFlightRef: changelogVersionCheckInFlightRef,
postCheck: postChangelogVersionCheck,
onShouldOpen: () => {
void requestOpenSettings({ changelog: true });
},
delayMs: 120,
retryDelayMs: 400,
});
}, [
appVersion,
authEnabled,
isSessionPending,
postChangelogVersionCheck,
sessionUserId,
requestOpenSettings,
]);
return {
requestOpenSettings,
};
}