import clsx from 'clsx'; import React, { useEffect, useState } from 'react'; import { useRouter } from 'next/navigation'; import { PiUserCircle, PiUserCircleCheck, PiGear } from 'react-icons/pi'; import { PiSun, PiMoon } from 'react-icons/pi'; import { TbSunMoon } from 'react-icons/tb'; import { MdCloudSync, MdSync, MdSyncProblem } from 'react-icons/md'; import { invoke, PermissionState } from '@tauri-apps/api/core'; import { isTauriAppPlatform, isWebAppPlatform } from '@/services/environment'; import { DOWNLOAD_READEST_URL } from '@/services/constants'; import { setBackupDialogVisible } from '@/app/library/components/BackupWindow'; import { setCacheManagerDialogVisible } from '@/app/library/components/CacheManagerWindow'; import { useAuth } from '@/context/AuthContext'; import { useEnv } from '@/context/EnvContext'; import { useThemeStore } from '@/store/themeStore'; import { useQuotaStats } from '@/hooks/useQuotaStats'; import { useFileSyncStore } from '@/store/fileSyncStore'; import { getCloudSyncProvider } from '@/services/sync/cloudSyncProvider'; import { useLibraryStore } from '@/store/libraryStore'; import { useSettingsStore } from '@/store/settingsStore'; import { useTranslation } from '@/hooks/useTranslation'; import { useResponsiveSize } from '@/hooks/useResponsiveSize'; import { useTransferQueue } from '@/hooks/useTransferQueue'; import { navigateToLogin, navigateToProfile } from '@/utils/nav'; import { tauriHandleSetAlwaysOnTop, tauriHandleToggleFullScreen } from '@/utils/window'; import { setAboutDialogVisible } from '@/components/AboutWindow'; import { setMigrateDataDirDialogVisible } from '@/app/library/components/MigrateDataWindow'; import { requestStoragePermission } from '@/utils/permission'; import { saveSysSettings } from '@/helpers/settings'; import { getBiometricStatus, getBiometryLabelKey, isBiometricSupported, } from '@/services/biometric'; import { selectDirectory } from '@/utils/bridge'; import dayjs from 'dayjs'; import UserAvatar from '@/components/UserAvatar'; import MenuItem from '@/components/MenuItem'; import Quota from '@/components/Quota'; import Menu from '@/components/Menu'; import { type AppLockDialogMode, useAppLockStore } from '@/store/appLockStore'; interface SettingsMenuProps { onPullLibrary: (fullRefresh?: boolean, verbose?: boolean) => void; setIsDropdownOpen?: (isOpen: boolean) => void; } interface Permissions { postNotification: PermissionState; manageStorage: PermissionState; } const SettingsMenu: React.FC = ({ onPullLibrary, setIsDropdownOpen }) => { const _ = useTranslation(); const router = useRouter(); const { envConfig, appService } = useEnv(); const { user } = useAuth(); const { userProfilePlan, quotas } = useQuotaStats(true); const { themeMode, setThemeMode } = useThemeStore(); const { settings, setSettingsDialogOpen } = useSettingsStore(); const [isAutoUpload, setIsAutoUpload] = useState(settings.autoUpload); const [isAlwaysOnTop, setIsAlwaysOnTop] = useState(settings.alwaysOnTop); const [isAlwaysShowStatusBar, setIsAlwaysShowStatusBar] = useState(settings.alwaysShowStatusBar); const [isOpenLastBooks, setIsOpenLastBooks] = useState(settings.openLastBooks); const [isAutoImportBooksOnOpen, setIsAutoImportBooksOnOpen] = useState( settings.autoImportBooksOnOpen, ); const [alwaysInForeground, setAlwaysInForeground] = useState(settings.alwaysInForeground); const [savedBookCoverForLockScreen, setSavedBookCoverForLockScreen] = useState( settings.savedBookCoverForLockScreen || '', ); const iconSize = useResponsiveSize(16); const [isRefreshingMetadata, setIsRefreshingMetadata] = useState(false); const [refreshMetadataProgress, setRefreshMetadataProgress] = useState(''); const { openDialog: openAppLockDialogInStore } = useAppLockStore(); const isPinEnabled = !!settings.pinCodeEnabled; const [biometricAvailable, setBiometricAvailable] = useState(false); const [biometryLabelKey, setBiometryLabelKey] = useState(''); const showBiometricToggle = !!appService?.isMobileApp && isPinEnabled && biometricAvailable; useEffect(() => { if (!isBiometricSupported(appService) || !isPinEnabled) return; let cancelled = false; void getBiometricStatus().then(({ available, biometryType }) => { if (cancelled) return; setBiometricAvailable(available); setBiometryLabelKey(getBiometryLabelKey(biometryType)); }); return () => { cancelled = true; }; }, [appService, isPinEnabled]); const toggleBiometricUnlock = () => { void saveSysSettings(envConfig, 'biometricUnlockEnabled', !settings.biometricUnlockEnabled); }; const openAppLockDialog = (mode: AppLockDialogMode) => { openAppLockDialogInStore(mode); setIsDropdownOpen?.(false); }; const { isSyncing, setLibrary } = useLibraryStore(); const fileSyncByKind = useFileSyncStore((s) => s.byKind); const fileSyncLastError = useFileSyncStore((s) => s.lastErrorByKind); const { stats, hasActiveTransfers, setIsTransferQueueOpen } = useTransferQueue(); const openTransferQueue = () => { setIsTransferQueueOpen(true); setIsDropdownOpen?.(false); }; const showAboutReadest = () => { setAboutDialogVisible(true); setIsDropdownOpen?.(false); }; const downloadReadest = () => { window.open(DOWNLOAD_READEST_URL, '_blank'); setIsDropdownOpen?.(false); }; const handleUserLogin = () => { navigateToLogin(router); setIsDropdownOpen?.(false); }; const handleUserProfile = () => { navigateToProfile(router); setIsDropdownOpen?.(false); }; const handleManageSync = () => { router.push('/user?section=sync'); setIsDropdownOpen?.(false); }; const cycleThemeMode = () => { const nextMode = themeMode === 'auto' ? 'light' : themeMode === 'light' ? 'dark' : 'auto'; setThemeMode(nextMode); }; const handleFullScreen = () => { tauriHandleToggleFullScreen(); setIsDropdownOpen?.(false); }; const toggleOpenInNewWindow = () => { saveSysSettings(envConfig, 'openBookInNewWindow', !settings.openBookInNewWindow); setIsDropdownOpen?.(false); }; const toggleAlwaysOnTop = () => { const newValue = !settings.alwaysOnTop; saveSysSettings(envConfig, 'alwaysOnTop', newValue); setIsAlwaysOnTop(newValue); tauriHandleSetAlwaysOnTop(newValue); setIsDropdownOpen?.(false); }; const toggleAlwaysShowStatusBar = () => { const newValue = !settings.alwaysShowStatusBar; saveSysSettings(envConfig, 'alwaysShowStatusBar', newValue); setIsAlwaysShowStatusBar(newValue); }; const toggleAutoUploadBooks = () => { const newValue = !settings.autoUpload; saveSysSettings(envConfig, 'autoUpload', newValue); setIsAutoUpload(newValue); if (newValue && !user) { navigateToLogin(router); } }; const toggleAutoImportBooksOnOpen = () => { const newValue = !settings.autoImportBooksOnOpen; saveSysSettings(envConfig, 'autoImportBooksOnOpen', newValue); setIsAutoImportBooksOnOpen(newValue); }; const toggleOpenLastBooks = () => { const newValue = !settings.openLastBooks; saveSysSettings(envConfig, 'openLastBooks', newValue); setIsOpenLastBooks(newValue); }; const handleUpgrade = () => { navigateToProfile(router); setIsDropdownOpen?.(false); }; const handleSetRootDir = () => { setMigrateDataDirDialogVisible(true); setIsDropdownOpen?.(false); }; const handleBackupRestore = () => { setIsDropdownOpen?.(false); setBackupDialogVisible(true); }; const handleManageCache = () => { setIsDropdownOpen?.(false); setCacheManagerDialogVisible(true); }; const handleRefreshMetadata = async () => { if (!appService || isRefreshingMetadata) return; setIsRefreshingMetadata(true); setRefreshMetadataProgress(_('Loading library...')); try { const books = await appService.loadLibraryBooks(); const activeBooks = books.filter((b) => !b.deletedAt); let refreshed = 0; for (let i = 0; i < activeBooks.length; i++) { setRefreshMetadataProgress(`${i + 1} / ${activeBooks.length}`); try { if (await appService.refreshBookMetadata(activeBooks[i]!)) { refreshed++; } } catch { // Skip books whose files can't be opened } } setLibrary(books); await appService.saveLibraryBooks(books); setRefreshMetadataProgress(_('{{count}} books refreshed', { count: refreshed })); onPullLibrary(true); setTimeout(() => { setIsRefreshingMetadata(false); setRefreshMetadataProgress(''); }, 2000); } catch (error) { console.error('Failed to refresh metadata:', error); setRefreshMetadataProgress(_('Failed to refresh metadata')); setTimeout(() => { setIsRefreshingMetadata(false); setRefreshMetadataProgress(''); }, 2000); } }; const openSettingsDialog = () => { setIsDropdownOpen?.(false); setSettingsDialogOpen(true); }; const handleSetSavedBookCoverForLockScreen = async () => { if (!(await requestStoragePermission()) && appService?.distChannel === 'readest') return; const newValue = settings.savedBookCoverForLockScreen ? '' : 'default'; if (newValue) { const response = await selectDirectory(); if (response.path) { saveSysSettings(envConfig, 'savedBookCoverForLockScreenPath', response.path); } } saveSysSettings(envConfig, 'savedBookCoverForLockScreen', newValue); setSavedBookCoverForLockScreen(newValue); }; const toggleAlwaysInForeground = async () => { const requestAlwaysInForeground = !settings.alwaysInForeground; if (requestAlwaysInForeground) { let permission = await invoke('plugin:native-tts|checkPermissions'); if (permission.postNotification !== 'granted') { permission = await invoke('plugin:native-tts|requestPermissions', { permissions: ['postNotification'], }); } if (permission.postNotification !== 'granted') return; } saveSysSettings(envConfig, 'alwaysInForeground', requestAlwaysInForeground); setAlwaysInForeground(requestAlwaysInForeground); }; const handleSyncLibrary = () => { onPullLibrary(true, true); setIsDropdownOpen?.(false); }; const avatarUrl = user?.user_metadata?.['picture'] || user?.user_metadata?.['avatar_url']; const userFullName = user?.user_metadata?.['full_name']; const userDisplayName = userFullName ? userFullName.split(' ')[0] : null; const themeModeLabel = themeMode === 'dark' ? _('Dark Mode') : themeMode === 'light' ? _('Light Mode') : _('Auto Mode'); const savedBookCoverPath = settings.savedBookCoverForLockScreenPath; const coverDir = savedBookCoverPath ? savedBookCoverPath.split('/').pop() : 'Images'; const savedBookCoverDescription = `💾 ${coverDir}/last-book-cover.png`; // While a third-party provider is selected the native cursors freeze (the // book/progress/note channels are gated), so the sync row must report the // file engine's health instead — otherwise it reads "Synced 3 months ago" // forever and looks broken. const cloudProvider = getCloudSyncProvider(settings); const cloudProviderName = cloudProvider === 'gdrive' ? 'Google Drive' : 'WebDAV'; const providerSyncing = cloudProvider !== 'readest' && !!fileSyncByKind[cloudProvider]?.isSyncing; const providerLastError = cloudProvider !== 'readest' ? fileSyncLastError[cloudProvider] : undefined; const lastSyncTime = cloudProvider !== 'readest' ? (cloudProvider === 'gdrive' ? settings.googleDrive?.lastSyncedAt : settings.webdav?.lastSyncedAt) || 0 : Math.max( settings.lastSyncedAtBooks || 0, settings.lastSyncedAtConfigs || 0, settings.lastSyncedAtNotes || 0, ); const syncRowLabel = cloudProvider !== 'readest' ? providerLastError ? _('Sync failed') : lastSyncTime ? _('Synced {{time}}', { time: dayjs(lastSyncTime).fromNow(), }) : _('Never synced') : lastSyncTime ? _('Synced {{time}}', { time: dayjs(lastSyncTime).fromNow() }) : _('Never synced'); return ( setIsDropdownOpen?.(false)} > {user ? ( ) : ( PiUserCircleCheck ) } >
    0 ? _('{{failedCount}} failed', { failedCount: stats.failed }) : '' } onClick={openTransferQueue} /> {cloudProvider === 'readest' ? ( ) : null}
) : ( )} {cloudProvider === 'readest' && ( )} {isTauriAppPlatform() && ( )} {isTauriAppPlatform() && ( )} {appService?.hasWindow && ( )} {appService?.hasWindow && } {appService?.hasWindow && ( )} {appService?.isMobileApp && ( )} {appService?.isAndroidApp && ( )}
    {appService?.canCustomizeRootDir && ( )} {user && } {appService?.isMobileApp && ( )} {!isPinEnabled && ( openAppLockDialog('set')} /> )} {isPinEnabled && ( openAppLockDialog('change')} /> )} {isPinEnabled && ( openAppLockDialog('disable')} /> )} {showBiometricToggle && ( )} {appService?.isAndroidApp && appService?.distChannel !== 'playstore' && ( )}
{user && userProfilePlan === 'free' && ( )} {isWebAppPlatform() && }
); }; export default SettingsMenu;