hard-cut onboarding modal flow orchestration
This commit is contained in:
parent
14b1b5602f
commit
22f132bbc8
7 changed files with 371 additions and 346 deletions
|
|
@ -4,16 +4,13 @@ import type { ReactNode } from 'react';
|
||||||
|
|
||||||
import { ConfigProvider } from '@/contexts/ConfigContext';
|
import { ConfigProvider } from '@/contexts/ConfigContext';
|
||||||
import { DocumentProvider } from '@/contexts/DocumentContext';
|
import { DocumentProvider } from '@/contexts/DocumentContext';
|
||||||
import { DexieMigrationModal } from '@/components/documents/DexieMigrationModal';
|
import { OnboardingFlowProvider } from '@/contexts/OnboardingFlowContext';
|
||||||
|
|
||||||
export default function AppHomeLayout({ children }: { children: ReactNode }) {
|
export default function AppHomeLayout({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<ConfigProvider>
|
<ConfigProvider>
|
||||||
<DocumentProvider>
|
<DocumentProvider>
|
||||||
<>
|
<OnboardingFlowProvider>{children}</OnboardingFlowProvider>
|
||||||
{children}
|
|
||||||
<DexieMigrationModal />
|
|
||||||
</>
|
|
||||||
</DocumentProvider>
|
</DocumentProvider>
|
||||||
</ConfigProvider>
|
</ConfigProvider>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import type { ReactNode } from 'react';
|
||||||
import { Toaster } from 'react-hot-toast';
|
import { Toaster } from 'react-hot-toast';
|
||||||
|
|
||||||
import { Providers } from '@/app/providers';
|
import { Providers } from '@/app/providers';
|
||||||
import ClaimDataPopup from '@/components/auth/ClaimDataModal';
|
|
||||||
import { getAuthBaseUrl, isAnonymousAuthSessionsEnabled, isAuthEnabled, isGithubAuthEnabled } from '@/lib/server/auth/config';
|
import { getAuthBaseUrl, isAnonymousAuthSessionsEnabled, isAuthEnabled, isGithubAuthEnabled } from '@/lib/server/auth/config';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
@ -36,7 +35,6 @@ export default function AppLayout({ children }: { children: ReactNode }) {
|
||||||
githubAuthEnabled={githubAuthEnabled}
|
githubAuthEnabled={githubAuthEnabled}
|
||||||
>
|
>
|
||||||
<div className="app-shell min-h-screen flex flex-col bg-background">
|
<div className="app-shell min-h-screen flex flex-col bg-background">
|
||||||
{authEnabled && <ClaimDataPopup />}
|
|
||||||
<main className="flex-1 flex flex-col">{children}</main>
|
<main className="flex-1 flex flex-col">{children}</main>
|
||||||
</div>
|
</div>
|
||||||
<Toaster
|
<Toaster
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,6 @@ import Link from 'next/link';
|
||||||
import { useTheme } from '@/contexts/ThemeContext';
|
import { useTheme } from '@/contexts/ThemeContext';
|
||||||
import { useConfig } from '@/contexts/ConfigContext';
|
import { useConfig } from '@/contexts/ConfigContext';
|
||||||
import { ChevronUpDownIcon, CheckIcon, SettingsIcon, KeyIcon, PaletteIcon, DocumentIcon, UserIcon, DownloadIcon, ChevronRightIcon } from '@/components/icons/Icons';
|
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 { useDocuments } from '@/contexts/DocumentContext';
|
||||||
import { ConfirmDialog } from '@/components/ConfirmDialog';
|
import { ConfirmDialog } from '@/components/ConfirmDialog';
|
||||||
import { ProgressPopup } from '@/components/ProgressPopup';
|
import { ProgressPopup } from '@/components/ProgressPopup';
|
||||||
|
|
@ -33,7 +32,6 @@ import { useAuthConfig } from '@/contexts/AuthRateLimitContext';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { showPrivacyModal } from '@/components/PrivacyModal';
|
import { showPrivacyModal } from '@/components/PrivacyModal';
|
||||||
import { deleteDocuments, mimeTypeForDoc, uploadDocuments } from '@/lib/client/api/documents';
|
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 { cacheStoredDocumentFromBytes, clearDocumentCache } from '@/lib/client/cache/documents';
|
||||||
import { clearAllDocumentPreviewCaches, clearInMemoryDocumentPreviewCache } from '@/lib/client/cache/previews';
|
import { clearAllDocumentPreviewCaches, clearInMemoryDocumentPreviewCache } from '@/lib/client/cache/previews';
|
||||||
import { resolveTtsSettingsViewModel } from '@/lib/client/settings/tts-settings';
|
import { resolveTtsSettingsViewModel } from '@/lib/client/settings/tts-settings';
|
||||||
|
|
@ -71,8 +69,7 @@ import {
|
||||||
type ChangelogManifestEntry,
|
type ChangelogManifestEntry,
|
||||||
type ChangelogReleaseBody,
|
type ChangelogReleaseBody,
|
||||||
} from '@/lib/shared/changelog';
|
} from '@/lib/shared/changelog';
|
||||||
import { useOnboardingCoordinator } from '@/hooks/useOnboardingCoordinator';
|
import { useOnboardingFlow } from '@/contexts/OnboardingFlowContext';
|
||||||
import { ONBOARDING_STATE_REGISTRY } from '@/lib/shared/onboarding-state';
|
|
||||||
|
|
||||||
// Hard-coded theme color palettes for the visual theme selector
|
// 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 };
|
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 [showDeleteAccountConfirm, setShowDeleteAccountConfirm] = useState(false);
|
||||||
const { progress, setProgress, estimatedTimeRemaining } = useTimeEstimation();
|
const { progress, setProgress, estimatedTimeRemaining } = useTimeEstimation();
|
||||||
const { authEnabled, baseUrl: authBaseUrl } = useAuthConfig();
|
const { authEnabled, baseUrl: authBaseUrl } = useAuthConfig();
|
||||||
const { data: session, isPending: isSessionPending } = useAuthSession();
|
const { data: session } = useAuthSession();
|
||||||
|
const { requestOpenSettings, registerSettingsController } = useOnboardingFlow();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const isBusy = isImportingLibrary;
|
const isBusy = isImportingLibrary;
|
||||||
const {
|
const {
|
||||||
|
|
@ -217,34 +215,15 @@ export function SettingsModal({ className = '' }: { className?: string }) {
|
||||||
setIsChangelogOpen(Boolean(options?.changelog));
|
setIsChangelogOpen(Boolean(options?.changelog));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const readLocalOnboardingSnapshot = useCallback(async () => {
|
useEffect(() => {
|
||||||
const appConfig = await getAppConfig();
|
registerSettingsController({
|
||||||
const row = appConfig as Record<string, unknown> | null;
|
open: openSettings,
|
||||||
const privacyKey = ONBOARDING_STATE_REGISTRY.privacyAccepted.localKey;
|
close: closeSettings,
|
||||||
const firstVisitKey = ONBOARDING_STATE_REGISTRY.firstVisitSettingsOpened.localKey;
|
});
|
||||||
|
return () => {
|
||||||
return {
|
registerSettingsController(null);
|
||||||
privacyAccepted: privacyKey ? Boolean(row?.[privacyKey]) : false,
|
|
||||||
firstVisitSettingsOpened: firstVisitKey ? Boolean(row?.[firstVisitKey]) : await getFirstVisit(),
|
|
||||||
};
|
};
|
||||||
}, []);
|
}, [closeSettings, openSettings, registerSettingsController]);
|
||||||
|
|
||||||
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,
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLocalApiKey(apiKey);
|
setLocalApiKey(apiKey);
|
||||||
|
|
@ -530,7 +509,11 @@ export function SettingsModal({ className = '' }: { className?: string }) {
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Transition appear show={isOpen} as={Fragment}>
|
<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
|
<TransitionChild
|
||||||
as={Fragment}
|
as={Fragment}
|
||||||
enter="ease-out duration-300"
|
enter="ease-out duration-300"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Fragment, useState, useEffect, useCallback } from 'react';
|
import { Fragment, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogPanel,
|
DialogPanel,
|
||||||
|
|
@ -9,11 +9,10 @@ import {
|
||||||
TransitionChild,
|
TransitionChild,
|
||||||
Button,
|
Button,
|
||||||
} from '@headlessui/react';
|
} from '@headlessui/react';
|
||||||
import { useAuthSession } from '@/hooks/useAuthSession';
|
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
type ClaimableCounts = {
|
export type ClaimableCounts = {
|
||||||
documents: number;
|
documents: number;
|
||||||
audiobooks: number;
|
audiobooks: number;
|
||||||
preferences: number;
|
preferences: number;
|
||||||
|
|
@ -30,53 +29,21 @@ function toClaimableCounts(value: unknown): ClaimableCounts {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ClaimDataModal() {
|
type ClaimDataModalProps = {
|
||||||
const { data: sessionData } = useAuthSession();
|
isOpen: boolean;
|
||||||
|
claimableCounts: ClaimableCounts;
|
||||||
|
onDismiss: () => void;
|
||||||
|
onClaimed: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ClaimDataModal({
|
||||||
|
isOpen,
|
||||||
|
claimableCounts,
|
||||||
|
onDismiss,
|
||||||
|
onClaimed,
|
||||||
|
}: ClaimDataModalProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
const [hasChecked, setHasChecked] = useState(false);
|
|
||||||
const [isClaiming, setIsClaiming] = 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 () => {
|
const handleClaim = async () => {
|
||||||
setIsClaiming(true);
|
setIsClaiming(true);
|
||||||
|
|
@ -93,8 +60,7 @@ export default function ClaimDataModal() {
|
||||||
+ `${claimed.preferences} preference set(s), and `
|
+ `${claimed.preferences} preference set(s), and `
|
||||||
+ `${claimed.progress} reading progress record(s)!`,
|
+ `${claimed.progress} reading progress record(s)!`,
|
||||||
);
|
);
|
||||||
|
onClaimed();
|
||||||
setIsOpen(false);
|
|
||||||
router.refresh();
|
router.refresh();
|
||||||
return;
|
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 (
|
return (
|
||||||
<Transition appear show={isOpen} as={Fragment}>
|
<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
|
<TransitionChild
|
||||||
as={Fragment}
|
as={Fragment}
|
||||||
enter="ease-out duration-300"
|
enter="ease-out duration-300"
|
||||||
|
|
@ -169,7 +129,7 @@ export default function ClaimDataModal() {
|
||||||
<div className="flex justify-end gap-3">
|
<div className="flex justify-end gap-3">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleDismiss}
|
onClick={onDismiss}
|
||||||
disabled={isClaiming}
|
disabled={isClaiming}
|
||||||
className="inline-flex justify-center rounded-lg bg-background px-3 py-1.5 text-sm
|
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
|
font-medium text-foreground hover:bg-offbase focus:outline-none
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,38 @@
|
||||||
'use client';
|
'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 { 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 { listDocuments, mimeTypeForDoc, uploadDocuments } from '@/lib/client/api/documents';
|
||||||
import { useDocuments } from '@/contexts/DocumentContext';
|
import { useDocuments } from '@/contexts/DocumentContext';
|
||||||
import type { BaseDocument } from '@/types/documents';
|
import type { BaseDocument } from '@/types/documents';
|
||||||
import { cacheStoredDocumentFromBytes } from '@/lib/client/cache/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 { refreshDocuments } = useDocuments();
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [displayMissingCount, setDisplayMissingCount] = useState(missingCount);
|
||||||
const [localCount, setLocalCount] = useState(0);
|
|
||||||
const [missingCount, setMissingCount] = useState(0);
|
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
const [progress, setProgress] = useState(0);
|
const [progress, setProgress] = useState(0);
|
||||||
const [status, setStatus] = useState<string>('');
|
const [status, setStatus] = useState<string>('');
|
||||||
const checkedRef = useRef(false);
|
|
||||||
|
|
||||||
const closeDisabled = isUploading;
|
const closeDisabled = isUploading;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDisplayMissingCount(missingCount);
|
||||||
|
}, [missingCount, isOpen]);
|
||||||
|
|
||||||
const loadLocalDexieDocs = useCallback(async (): Promise<{
|
const loadLocalDexieDocs = useCallback(async (): Promise<{
|
||||||
docs: BaseDocument[];
|
docs: BaseDocument[];
|
||||||
pdfById: Map<string, { id: string; name: string; size: number; lastModified: number; data: ArrayBuffer }>;
|
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 };
|
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 title = 'Upload your local documents?';
|
||||||
|
|
||||||
const handleSkip = useCallback(async () => {
|
const handleSkip = useCallback(async () => {
|
||||||
await updateAppConfig({ documentsMigrationPrompted: true });
|
await updateAppConfig({ documentsMigrationPrompted: true });
|
||||||
setIsOpen(false);
|
onComplete();
|
||||||
}, []);
|
}, [onComplete]);
|
||||||
|
|
||||||
const handleUpload = useCallback(async () => {
|
const handleUpload = useCallback(async () => {
|
||||||
setIsUploading(true);
|
setIsUploading(true);
|
||||||
|
|
@ -104,7 +69,7 @@ export function DexieMigrationModal() {
|
||||||
const serverDocs = await listDocuments().catch(() => null);
|
const serverDocs = await listDocuments().catch(() => null);
|
||||||
const serverIds = serverDocs ? new Set(serverDocs.map((d) => d.id)) : null;
|
const serverIds = serverDocs ? new Set(serverDocs.map((d) => d.id)) : null;
|
||||||
const toUpload = serverIds ? docs.filter((d) => !serverIds.has(d.id)) : docs;
|
const toUpload = serverIds ? docs.filter((d) => !serverIds.has(d.id)) : docs;
|
||||||
setMissingCount(toUpload.length);
|
setDisplayMissingCount(toUpload.length);
|
||||||
|
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
for (let i = 0; i < toUpload.length; i++) {
|
for (let i = 0; i < toUpload.length; i++) {
|
||||||
|
|
@ -159,21 +124,20 @@ export function DexieMigrationModal() {
|
||||||
setStatus('Refreshing...');
|
setStatus('Refreshing...');
|
||||||
await refreshDocuments();
|
await refreshDocuments();
|
||||||
await updateAppConfig({ documentsMigrationPrompted: true });
|
await updateAppConfig({ documentsMigrationPrompted: true });
|
||||||
setIsOpen(false);
|
onComplete();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Dexie migration upload failed:', err);
|
console.error('Dexie migration upload failed:', err);
|
||||||
setStatus('Upload failed. You can retry or skip.');
|
setStatus('Upload failed. You can retry or skip.');
|
||||||
checkedRef.current = false;
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsUploading(false);
|
setIsUploading(false);
|
||||||
}
|
}
|
||||||
}, [loadLocalDexieDocs, refreshDocuments]);
|
}, [loadLocalDexieDocs, onComplete, refreshDocuments]);
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Transition appear show={isOpen} as={Fragment}>
|
<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
|
<TransitionChild
|
||||||
as={Fragment}
|
as={Fragment}
|
||||||
enter="ease-out duration-300"
|
enter="ease-out duration-300"
|
||||||
|
|
@ -204,8 +168,8 @@ export function DexieMigrationModal() {
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="text-sm text-muted mb-2">
|
<p className="text-sm text-muted mb-2">
|
||||||
Found {localCount} document{localCount === 1 ? '' : 's'} stored locally from an older version.
|
Found {localCount} document{localCount === 1 ? '' : 's'} stored locally from an older version.
|
||||||
{missingCount > 0 ? (
|
{displayMissingCount > 0 ? (
|
||||||
<> {missingCount} {missingCount === 1 ? 'is' : 'are'} not here yet.</>
|
<> {displayMissingCount} {displayMissingCount === 1 ? 'is' : 'are'} not here yet.</>
|
||||||
) : null}
|
) : null}
|
||||||
{' '}This app now stores documents on the server and keeps a local cache for speed.
|
{' '}This app now stores documents on the server and keeps a local cache for speed.
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
307
src/contexts/OnboardingFlowContext.tsx
Normal file
307
src/contexts/OnboardingFlowContext.tsx
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
Loading…
Reference in a new issue