import React, {ReactNode, useCallback, useContext, useEffect, useRef, useState} from 'react'; import {BookContext} from '@/context/BookContext'; import {ChapterProps} from '@/lib/types/chapter'; import {ChapterContext} from '@/context/ChapterContext'; import {EditorContext} from '@/context/EditorContext'; import {Editor, useEditor} from '@tiptap/react'; import StarterKit from '@tiptap/starter-kit'; import Underline from '@tiptap/extension-underline'; import TextAlign from '@tiptap/extension-text-align'; import {AlertContext, AlertContextProps, AlertProvider} from '@/context/AlertContext'; import {usePathname} from '@/lib/navigation'; import {apiPost} from '@/lib/api/client'; import {getCookie} from '@/lib/utils/cookies'; import {SessionContext, SessionContextProps} from '@/context/SessionContext'; import {BookProps} from '@/lib/types/book'; import ScribeControllerBar from '@/components/layout/ScribeControllerBar'; import ScribeLeftBar from '@/components/leftbar/ScribeLeftBar'; import ComposerRightBar from '@/components/rightbar/ComposerRightBar'; import ScribeFooterBar from '@/components/layout/ScribeFooterBar'; import GuideTour from '@/components/GuideTour'; import TermsOfUse from '@/components/TermsOfUse'; import {useTranslations, changeLanguage} from '@/lib/i18n'; import {isSupportedLocale, LangContext, LangContextProps, SupportedLocale} from '@/context/LangContext'; import {ThemeContext} from '@/context/ThemeContext'; import useTheme from '@/hooks/useTheme'; import {AIUsageContext} from '@/context/AIUsageContext'; import {BookSyncCompare, compareBookSyncs, SyncedBook} from '@/lib/types/synced-book'; import {SeriesSyncCompare, compareSeriesSyncs, SyncedSeries} from '@/lib/types/synced-series'; import {BooksSyncContext} from '@/context/BooksSyncContext'; import {SeriesSyncContext} from '@/context/SeriesSyncContext'; import {LocalSyncQueueContext, LocalSyncOperation} from '@/context/SyncQueueContext'; import useAuthentication from '@/hooks/useAuthentication'; import useOnboarding from '@/hooks/useOnboarding'; import {SettingBookContext} from '@/context/SettingBookContext'; import PulseLoader from '@/components/ui/PulseLoader'; import {initCrashReporter} from '@/lib/crashReporter'; import OfflineProvider from '@/context/OfflineProvider'; import OfflineContext, {OfflineContextType} from '@/context/OfflineContext'; import OfflinePinSetup from '@/components/offline/OfflinePinSetup'; import OfflinePinVerify from '@/components/offline/OfflinePinVerify'; import MigrationModal from '@/components/migration/MigrationModal'; import {isDesktop} from '@/lib/configs'; import * as tauri from '@/lib/tauri'; import useSyncBooks from '@/hooks/useSyncBooks'; import useSyncSeries from '@/hooks/useSyncSeries'; function AutoSyncOnReconnect() { const {offlineMode}: OfflineContextType = useContext(OfflineContext); const {session}: SessionContextProps = useContext(SessionContext); const {syncAllToServer: syncAllBooksToServer, syncAllFromServer: syncAllBooksFromServer, refreshBooks, booksToSyncToServer, booksToSyncFromServer} = useSyncBooks(); const {syncAllToServer: syncAllSeriesToServer, syncAllFromServer: syncAllSeriesFromServer, refreshSeries, seriesToSyncToServer, seriesToSyncFromServer} = useSyncSeries(); const isSyncingRef = useRef(false); const hasRefreshedRef = useRef(false); const saveLastOnlineTimestamp = useCallback((): void => { const timestamp: number = Math.floor(Date.now() / 1000); localStorage.setItem('lastOnlineTimestamp', timestamp.toString()); }, []); // Refresh sync data when online + authenticated + DB ready useEffect((): void => { if (!offlineMode.isOffline && session.isConnected && offlineMode.isDatabaseInitialized) { hasRefreshedRef.current = true; Promise.all([refreshBooks(), refreshSeries()]); } }, [offlineMode.isOffline, session.isConnected, offlineMode.isDatabaseInitialized]); // Auto-sync when diffs become available useEffect((): void => { if (offlineMode.isOffline || !session.isConnected || isSyncingRef.current || !hasRefreshedRef.current) return; const syncPromises: Promise[] = []; if (booksToSyncToServer.length > 0) syncPromises.push(syncAllBooksToServer()); if (booksToSyncFromServer.length > 0) syncPromises.push(syncAllBooksFromServer()); if (seriesToSyncToServer.length > 0) syncPromises.push(syncAllSeriesToServer()); if (seriesToSyncFromServer.length > 0) syncPromises.push(syncAllSeriesFromServer()); if (syncPromises.length > 0) { isSyncingRef.current = true; Promise.all(syncPromises).then((): void => { saveLastOnlineTimestamp(); isSyncingRef.current = false; }).catch((): void => { isSyncingRef.current = false; }); } }, [booksToSyncToServer, booksToSyncFromServer, seriesToSyncToServer, seriesToSyncFromServer]); // Update lastOnlineTimestamp every 5 minutes while online useEffect((): (() => void) | void => { if (!offlineMode.isOffline && session.isConnected) { const intervalId: ReturnType = setInterval((): void => { saveLastOnlineTimestamp(); }, 5 * 60 * 1000); return (): void => clearInterval(intervalId); } }, [offlineMode.isOffline, session.isConnected, saveLastOnlineTimestamp]); return null; } function ScribeContent({children}: { children: ReactNode }) { const t = useTranslations(); const {lang: locale}: LangContextProps = useContext(LangContext); const {errorMessage}: AlertContextProps = useContext(AlertContext); const {isCurrentlyOffline, offlineMode}: OfflineContextType = useContext(OfflineContext); const { session, setSession, isLoading, currentCredits, setCurrentCredits, amountSpent, setAmountSpent, showPinSetup, setShowPinSetup, showPinVerify, setShowPinVerify, handlePinVerifySuccess } = useAuthentication(); const { isTermsAccepted, homeStepsGuide, setHomeStepsGuide, handleTermsAcceptance, handleHomeTour, homeSteps } = useOnboarding({session, setSession}); const editor: Editor | null = useEditor({ extensions: [ StarterKit, Underline, TextAlign.configure({ types: ['heading', 'paragraph'], }), ], injectCSS: false, immediatelyRender: false, shouldRerenderOnTransaction: true, }); const pathname: string = usePathname(); const [currentChapter, setCurrentChapter] = useState(undefined); const [currentBook, setCurrentBook] = useState(null); const [bookSettingId, setBookSettingId] = useState(''); const [showMigrationPopup, setShowMigrationPopup] = useState(false); useEffect(function (): void { if (!isDesktop) return; const done: boolean = localStorage.getItem('electron_migration_done') === 'true'; const dismissed: boolean = localStorage.getItem('electron_migration_dismissed') === 'true'; if (!done && !dismissed) { setShowMigrationPopup(true); } }, []); const [serverSyncedBooks, setServerSyncedBooks] = useState([]); const [localSyncedBooks, setLocalSyncedBooks] = useState([]); const [bookSyncDiffsFromServer, setBookSyncDiffsFromServer] = useState([]); const [bookSyncDiffsToServer, setBookSyncDiffsToServer] = useState([]); const [serverOnlyBooks, setServerOnlyBooks] = useState([]); const [localOnlyBooks, setLocalOnlyBooks] = useState([]); const [serverSyncedSeries, setServerSyncedSeries] = useState([]); const [localSyncedSeries, setLocalSyncedSeries] = useState([]); const [seriesSyncDiffsFromServer, setSeriesSyncDiffsFromServer] = useState([]); const [seriesSyncDiffsToServer, setSeriesSyncDiffsToServer] = useState([]); const [serverOnlySeries, setServerOnlySeries] = useState([]); const [localOnlySeries, setLocalOnlySeries] = useState([]); const [localSyncQueue, setLocalSyncQueue] = useState([]); const [isQueueProcessing, setIsQueueProcessing] = useState(false); function addToLocalSyncQueue(channel: string, data: Record): void { const operation: LocalSyncOperation = { id: `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`, channel, data, timestamp: Date.now(), }; setLocalSyncQueue((prev: LocalSyncOperation[]): LocalSyncOperation[] => [...prev, operation]); } useEffect((): void => { if (localSyncQueue.length === 0 || isQueueProcessing) return; async function processQueue(): Promise { setIsQueueProcessing(true); const queueCopy: LocalSyncOperation[] = [...localSyncQueue]; for (const operation of queueCopy) { try { await tauri.invoke(operation.channel, {data: operation.data}); setLocalSyncQueue((prev: LocalSyncOperation[]): LocalSyncOperation[] => prev.filter((op: LocalSyncOperation): boolean => op.id !== operation.id) ); } catch (_e) { /* queue retry on next cycle */ } } setIsQueueProcessing(false); } processQueue().then(); }, [localSyncQueue, isQueueProcessing]); useEffect((): void => { if (pathname === '/') { setCurrentBook(null); setCurrentChapter(undefined); } }, [pathname]); useEffect((): void => { if (!session.isConnected) return; // On desktop with DB initialized, AutoSyncOnReconnect handles fetching + tombstone exchange if (isDesktop && offlineMode.isDatabaseInitialized) return; getBooks().then(); getSeries().then(); }, [session, offlineMode.isDatabaseInitialized]); useEffect((): void => { const diffsFromServer: BookSyncCompare[] = []; const diffsToServer: BookSyncCompare[] = []; serverSyncedBooks.forEach((serverBook: SyncedBook): void => { const localBook: SyncedBook | undefined = localSyncedBooks.find((book: SyncedBook): boolean => book.id === serverBook.id); if (!localBook) return; const diff: BookSyncCompare | null = compareBookSyncs(serverBook, localBook); if (diff) diffsFromServer.push(diff); }); localSyncedBooks.forEach((localBook: SyncedBook): void => { const serverBook: SyncedBook | undefined = serverSyncedBooks.find((book: SyncedBook): boolean => book.id === localBook.id); if (!serverBook) return; const diff: BookSyncCompare | null = compareBookSyncs(localBook, serverBook); if (diff) diffsToServer.push(diff); }); setBookSyncDiffsFromServer(diffsFromServer); setBookSyncDiffsToServer(diffsToServer); setServerOnlyBooks(serverSyncedBooks.filter((serverBook: SyncedBook): boolean => !localSyncedBooks.find((localBook: SyncedBook): boolean => localBook.id === serverBook.id))); setLocalOnlyBooks(localSyncedBooks.filter((localBook: SyncedBook): boolean => !serverSyncedBooks.find((serverBook: SyncedBook): boolean => serverBook.id === localBook.id))); }, [localSyncedBooks, serverSyncedBooks]); useEffect((): void => { const diffsFromServer: SeriesSyncCompare[] = []; const diffsToServer: SeriesSyncCompare[] = []; serverSyncedSeries.forEach((serverSeries: SyncedSeries): void => { const localSeries: SyncedSeries | undefined = localSyncedSeries.find((series: SyncedSeries): boolean => series.id === serverSeries.id); if (!localSeries) return; const diff: SeriesSyncCompare | null = compareSeriesSyncs(serverSeries, localSeries); if (diff) diffsFromServer.push(diff); }); localSyncedSeries.forEach((localSeries: SyncedSeries): void => { const serverSeries: SyncedSeries | undefined = serverSyncedSeries.find((series: SyncedSeries): boolean => series.id === localSeries.id); if (!serverSeries) return; const diff: SeriesSyncCompare | null = compareSeriesSyncs(localSeries, serverSeries); if (diff) diffsToServer.push(diff); }); setSeriesSyncDiffsFromServer(diffsFromServer); setSeriesSyncDiffsToServer(diffsToServer); setServerOnlySeries(serverSyncedSeries.filter((s: SyncedSeries): boolean => !localSyncedSeries.find((l: SyncedSeries): boolean => l.id === s.id))); setLocalOnlySeries(localSyncedSeries.filter((l: SyncedSeries): boolean => !serverSyncedSeries.find((s: SyncedSeries): boolean => s.id === l.id))); }, [localSyncedSeries, serverSyncedSeries]); async function getBooks(): Promise { try { let localBooksResponse: SyncedBook[] = []; let serverBooksResponse: SyncedBook[] = []; if (!isCurrentlyOffline()) { if (isDesktop && offlineMode.isDatabaseInitialized) { localBooksResponse = await tauri.getSyncedBooks() as SyncedBook[]; const lastOnlineStr: string | null = localStorage.getItem('lastOnlineTimestamp'); const lastOnlineTimestamp: number = lastOnlineStr ? parseInt(lastOnlineStr, 10) : 0; const localTombstones: tauri.TombstoneRecord[] = await tauri.getTombstonesSince(lastOnlineTimestamp) as tauri.TombstoneRecord[]; const serverResponse: { books: SyncedBook[]; tombstones: tauri.TombstoneRecord[] } = await apiPost<{ books: SyncedBook[]; tombstones: tauri.TombstoneRecord[]; }>('books/synced', { lastOnlineTimestamp, tombstones: localTombstones }, session.accessToken, locale); serverBooksResponse = serverResponse.books; await tauri.applyBookTombstones(serverResponse.tombstones ?? []); } else { const serverResponse: { books: SyncedBook[] } = await apiPost<{ books: SyncedBook[] }>('books/synced', { lastOnlineTimestamp: 0, tombstones: [] }, session.accessToken, locale); if (serverResponse) serverBooksResponse = serverResponse.books; } } else { if (isDesktop && offlineMode.isDatabaseInitialized) { localBooksResponse = await tauri.getSyncedBooks() as SyncedBook[]; } } setServerSyncedBooks(serverBooksResponse); setLocalSyncedBooks(localBooksResponse); } catch (e: unknown) { if (e instanceof Error) { errorMessage(e.message); } else { errorMessage(t('homePage.errors.fetchBooksError')); } } } async function getSeries(): Promise { try { let localSeriesResponse: SyncedSeries[] = []; let serverSeriesResponse: SyncedSeries[] = []; if (!isCurrentlyOffline()) { if (isDesktop && offlineMode.isDatabaseInitialized) { localSeriesResponse = await tauri.getSyncedSeries() as SyncedSeries[]; const lastOnlineStr: string | null = localStorage.getItem('lastOnlineTimestamp'); const lastOnlineTimestamp: number = lastOnlineStr ? parseInt(lastOnlineStr, 10) : 0; const localTombstones: tauri.TombstoneRecord[] = await tauri.getTombstonesSince(lastOnlineTimestamp) as tauri.TombstoneRecord[]; const serverResponse: { series: SyncedSeries[]; tombstones: tauri.TombstoneRecord[] } = await apiPost<{ series: SyncedSeries[]; tombstones: tauri.TombstoneRecord[]; }>('series/synced', { lastOnlineTimestamp, tombstones: localTombstones }, session.accessToken, locale); serverSeriesResponse = serverResponse.series; await tauri.applySeriesTombstones(serverResponse.tombstones ?? []); } else { const serverResponse: { series: SyncedSeries[] } = await apiPost<{ series: SyncedSeries[] }>('series/synced', { lastOnlineTimestamp: 0, tombstones: [] }, session.accessToken, locale); if (serverResponse) serverSeriesResponse = serverResponse.series; } } else { if (isDesktop && offlineMode.isDatabaseInitialized) { localSeriesResponse = await tauri.getSyncedSeries() as SyncedSeries[]; } } setServerSyncedSeries(serverSeriesResponse); setLocalSyncedSeries(localSeriesResponse); } catch (e: unknown) { if (e instanceof Error) { errorMessage(e.message); } else { errorMessage(t('homePage.errors.fetchBooksError')); } } } if (isLoading) { return (
ERitors Logo
); } return ( {isDesktop && }
{children}
{homeStepsGuide && setHomeStepsGuide(false)}/> } {!isCurrentlyOffline() && !isTermsAccepted && } {isDesktop && showPinSetup && ( setShowPinSetup(false)} onSuccess={(): void => setShowPinSetup(false)} /> )} {isDesktop && showPinVerify && ( {}} /> )} {showMigrationPopup && ( )}
); } export default function ScribeShell({children}: { children: ReactNode }) { const [locale, setLocale] = useState('fr'); const {theme, setTheme} = useTheme(); useEffect((): void => { initCrashReporter(); }, []); useEffect((): void => { const lang: string | null = getCookie('lang'); if (lang && isSupportedLocale(lang)) { setLocale(lang); } }, []); useEffect((): void => { changeLanguage(locale); }, [locale]); return ( {children} ); }