sync: add sync status menu item in view menu, closes #844 (#1324)

This commit is contained in:
Huang Xin
2025-06-03 17:43:58 +08:00
committed by GitHub
parent 146a71fdef
commit 3f8d03ae28
22 changed files with 142 additions and 57 deletions
@@ -1,16 +1,22 @@
import clsx from 'clsx';
import React, { useEffect } from 'react';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { BiMoon, BiSun } from 'react-icons/bi';
import { TbSunMoon } from 'react-icons/tb';
import { MdZoomOut, MdZoomIn, MdCheck } from 'react-icons/md';
import { MdSync, MdSyncProblem } from 'react-icons/md';
import { MAX_ZOOM_LEVEL, MIN_ZOOM_LEVEL, ZOOM_STEP } from '@/services/constants';
import { useEnv } from '@/context/EnvContext';
import { useAuth } from '@/context/AuthContext';
import { useThemeStore } from '@/store/themeStore';
import { useReaderStore } from '@/store/readerStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { useTranslation } from '@/hooks/useTranslation';
import { getStyles } from '@/utils/style';
import { navigateToLogin } from '@/utils/nav';
import { eventDispatcher } from '@/utils/event';
import { getMaxInlineSize } from '@/utils/config';
import { tauriHandleToggleFullScreen } from '@/utils/window';
import { saveViewSettings } from '../utils/viewSettingsHelper';
@@ -28,8 +34,12 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
onSetSettingsDialogOpen,
}) => {
const _ = useTranslation();
const router = useRouter();
const { user } = useAuth();
const { envConfig, appService } = useEnv();
const { getConfig } = useBookDataStore();
const { getView, getViewSettings, setViewSettings } = useReaderStore();
const config = getConfig(bookKey)!;
const viewSettings = getViewSettings(bookKey)!;
const { themeMode, isDarkMode, setThemeMode } = useThemeStore();
@@ -59,6 +69,15 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
setIsDropdownOpen?.(false);
};
const handleSync = () => {
if (!user) {
navigateToLogin(router);
setIsDropdownOpen?.(false);
} else {
eventDispatcher.dispatch('sync-book-progress', { bookKey });
}
};
useEffect(() => {
if (isScrolledMode === viewSettings!.scrolled) return;
viewSettings!.scrolled = isScrolledMode;
@@ -83,10 +102,12 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [invertImgColorInDark]);
const lastSyncTime = Math.max(config?.lastSyncedAtConfig || 0, config?.lastSyncedAtNotes || 0);
return (
<div
tabIndex={0}
className='view-menu dropdown-content bgcolor-base-200 dropdown-right no-triangle border-base-200 z-20 mt-1 w-72 border shadow-2xl'
className='view-menu dropdown-content bgcolor-base-200 dropdown-right no-triangle border-base-200 z-20 mt-1 border shadow-2xl'
>
<div className={clsx('flex items-center justify-between rounded-md')}>
<button
@@ -130,6 +151,22 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
<hr className='border-base-300 my-1' />
<MenuItem
label={
!user
? _('Sign in to Sync')
: lastSyncTime
? _('Synced at {{time}}', {
time: new Date(lastSyncTime).toLocaleString(),
})
: _('Never synced')
}
Icon={user ? MdSync : MdSyncProblem}
onClick={handleSync}
/>
<hr className='border-base-300 my-1' />
{appService?.hasWindow && <MenuItem label={_('Fullscreen')} onClick={handleFullScreen} />}
<MenuItem
label={
@@ -1,4 +1,4 @@
import { useEffect, useRef } from 'react';
import { useCallback, useEffect, useRef } from 'react';
import { useAuth } from '@/context/AuthContext';
import { useSync } from '@/hooks/useSync';
import { BookConfig } from '@/types/book';
@@ -8,6 +8,7 @@ import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation';
import { serializeConfig } from '@/utils/serializer';
import { CFI } from '@/libs/document';
import { debounce } from '@/utils/debounce';
import { eventDispatcher } from '@/utils/event';
import { DEFAULT_BOOK_SEARCH_CONFIG, SYNC_PROGRESS_INTERVAL_SEC } from '@/services/constants';
@@ -21,9 +22,9 @@ export const useProgressSync = (bookKey: string) => {
const view = getView(bookKey);
const config = getConfig(bookKey);
const progress = getProgress(bookKey);
// flag to prevent accidental sync without first pulling the config
const configSynced = useRef(false);
const firstPulled = useRef(false);
const configPulled = useRef(false);
const hasPulledConfigOnce = useRef(false);
const pushConfig = (bookKey: string, config: BookConfig | null) => {
if (!config || !user) return;
@@ -41,9 +42,10 @@ export const useProgressSync = (bookKey: string) => {
syncConfigs([], bookHash, 'pull');
};
const syncConfig = () => {
if (!configSynced.current) {
if (!configPulled.current) {
pullConfig(bookKey);
} else {
const config = getConfig(bookKey);
if (config && config.progress && config.progress[0] > 0) {
pushConfig(bookKey, config);
}
@@ -57,6 +59,7 @@ export const useProgressSync = (bookKey: string) => {
}
};
// Push: ad-hoc push when the book is closed
useEffect(() => {
eventDispatcher.on('sync-book-progress', handleSyncBookProgress);
return () => {
@@ -65,45 +68,33 @@ export const useProgressSync = (bookKey: string) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [bookKey]);
useEffect(() => {
if (!progress || firstPulled.current) return;
firstPulled.current = true;
pullConfig(bookKey);
return () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
const debouncedAutoSync = useCallback(
debounce(() => {
syncConfig();
};
}, SYNC_PROGRESS_INTERVAL_SEC * 1000),
[],
);
// Push: auto-push progress when progress changes with a debounce
useEffect(() => {
if (!progress?.location || !user) return;
debouncedAutoSync();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [progress]);
const lastProgressSyncTime = useRef<number>(0);
const syncTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Pull: pull progress once when the book is opened
useEffect(() => {
if (!config?.location || !user) return;
const now = Date.now();
const timeSinceLastSync = now - lastProgressSyncTime.current;
if (timeSinceLastSync > SYNC_PROGRESS_INTERVAL_SEC * 1000) {
lastProgressSyncTime.current = now;
syncConfig();
} else {
if (syncTimeoutRef.current) clearTimeout(syncTimeoutRef.current);
syncTimeoutRef.current = setTimeout(
() => {
lastProgressSyncTime.current = Date.now();
syncTimeoutRef.current = null;
syncConfig();
},
SYNC_PROGRESS_INTERVAL_SEC * 1000 - timeSinceLastSync,
);
}
if (!progress || hasPulledConfigOnce.current) return;
hasPulledConfigOnce.current = true;
pullConfig(bookKey);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [config]);
}, [progress]);
// sync progress once when the book is opened
// Pull: proccess the pulled progress
useEffect(() => {
if (!configSynced.current && syncedConfigs) {
configSynced.current = true;
if (!configPulled.current && syncedConfigs) {
configPulled.current = true;
const syncedConfig = syncedConfigs.filter((c) => c.bookHash === bookKey.split('-')[0])[0];
if (syncedConfig) {
const configCFI = config?.location;