From 06aec0b597f3dfb76283bc50c242de2616cf6cd1 Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Tue, 5 May 2026 23:27:11 +0800 Subject: [PATCH] fix(reader): revert footer to default visibility when tap-to-toggle is disabled (#4065) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tapping the footer with `tapToToggleFooter` on cycles `progressInfoMode` through values including 'none', which persists to view settings. When the user later disabled the toggle in settings, nothing reverted the saved mode — so the footer stayed hidden with no UI path back to a visible state, only re-enabling the toggle and tap-cycling forward. ProgressBar now self-heals: when `tapToToggleFooter` is off and the current mode isn't already 'all', it resets to 'all'. Fires both at mount (book opened with stuck 'none') and on the toggle transition. Co-authored-by: Claude Opus 4.7 (1M context) --- .../__tests__/components/ProgressBar.test.tsx | 123 ++++++++++++++++++ .../src/app/reader/components/ProgressBar.tsx | 12 ++ 2 files changed, 135 insertions(+) create mode 100644 apps/readest-app/src/__tests__/components/ProgressBar.test.tsx diff --git a/apps/readest-app/src/__tests__/components/ProgressBar.test.tsx b/apps/readest-app/src/__tests__/components/ProgressBar.test.tsx new file mode 100644 index 00000000..71d1d227 --- /dev/null +++ b/apps/readest-app/src/__tests__/components/ProgressBar.test.tsx @@ -0,0 +1,123 @@ +import { cleanup, render } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import ProgressBar from '@/app/reader/components/ProgressBar'; +import { DEFAULT_VIEW_CONFIG } from '@/services/constants'; +import type { ViewSettings } from '@/types/book'; + +const saveViewSettings = vi.fn(); + +let currentViewSettings: ViewSettings; + +vi.mock('@/hooks/useTranslation', () => ({ + useTranslation: () => (s: string) => s, +})); + +vi.mock('@/context/EnvContext', () => ({ + useEnv: () => ({ envConfig: {}, appService: { isMobile: false, hasSafeAreaInset: false } }), +})); + +vi.mock('@/store/readerStore', () => ({ + useReaderStore: () => ({ + getProgress: () => null, + getViewSettings: () => currentViewSettings, + getView: () => ({ renderer: { page: 0, pages: 0 } }), + }), +})); + +vi.mock('@/store/bookDataStore', () => ({ + useBookDataStore: () => ({ + getBookData: () => ({ isFixedLayout: false }), + }), +})); + +vi.mock('@/helpers/settings', () => ({ + saveViewSettings: (...args: unknown[]) => saveViewSettings(...args), +})); + +vi.mock('@/utils/event', () => ({ + eventDispatcher: { dispatchSync: () => false }, +})); + +vi.mock('@/app/reader/components/StatusInfo.tsx', () => ({ + default: () => null, +})); + +const baseSettings: ViewSettings = { + ...DEFAULT_VIEW_CONFIG, +} as ViewSettings; + +const renderProgressBar = () => + render( + , + ); + +afterEach(() => { + cleanup(); +}); + +beforeEach(() => { + saveViewSettings.mockClear(); +}); + +describe('ProgressBar — tap-to-toggle disabled reverts hidden footer', () => { + it("resets progressInfoMode to 'all' when the user disables tapToToggleFooter while mode was 'none'", () => { + // Simulate a user who tapped the footer to dismiss it (mode='none') + // while tapToToggleFooter was on. Now they have it switched off. + currentViewSettings = { + ...baseSettings, + tapToToggleFooter: false, + progressInfoMode: 'none', + } as ViewSettings; + + renderProgressBar(); + + // The persisted progressInfoMode should be reset to the default + // ('all') so the footer reverts to its default visibility. + const persistCalls = saveViewSettings.mock.calls.filter( + (args) => args[2] === 'progressInfoMode', + ); + expect(persistCalls.length).toBeGreaterThanOrEqual(1); + const lastCall = persistCalls[persistCalls.length - 1]!; + expect(lastCall[3]).toBe('all'); + }); + + it("does not overwrite mode when tapToToggleFooter is on (user's cycled state stays)", () => { + currentViewSettings = { + ...baseSettings, + tapToToggleFooter: true, + progressInfoMode: 'none', + } as ViewSettings; + + renderProgressBar(); + + // initial save mirrors the existing mode; importantly we never see + // a save with 'all' overriding the user's tap-cycled choice. + const persistCalls = saveViewSettings.mock.calls.filter( + (args) => args[2] === 'progressInfoMode', + ); + expect(persistCalls.every((args) => args[3] === 'none')).toBe(true); + }); + + it("leaves mode untouched when tapToToggleFooter is off but mode is already 'all'", () => { + currentViewSettings = { + ...baseSettings, + tapToToggleFooter: false, + progressInfoMode: 'all', + } as ViewSettings; + + renderProgressBar(); + + const persistCalls = saveViewSettings.mock.calls.filter( + (args) => args[2] === 'progressInfoMode', + ); + // Either no save or a save matching the existing 'all' value — never + // a transition through some intermediate state. + expect(persistCalls.every((args) => args[3] === 'all')).toBe(true); + }); +}); diff --git a/apps/readest-app/src/app/reader/components/ProgressBar.tsx b/apps/readest-app/src/app/reader/components/ProgressBar.tsx index ad89d888..9a23b360 100644 --- a/apps/readest-app/src/app/reader/components/ProgressBar.tsx +++ b/apps/readest-app/src/app/reader/components/ProgressBar.tsx @@ -146,6 +146,18 @@ const ProgressBar: React.FC = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [progressBarMode]); + // Self-heal a stuck "none" (or partial) mode left over from a prior + // tap-to-toggle session. Without this, dismissing the footer via tap + // and then disabling the toggle in settings would leave the footer + // permanently hidden — the user's only way back to a visible footer + // would be to re-enable the toggle and tap through the cycle. + useEffect(() => { + if (!viewSettings.tapToToggleFooter && progressBarMode !== 'all') { + setProgressBarMode('all'); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [viewSettings.tapToToggleFooter]); + const isMobile = appService?.isMobile || window.innerWidth < 640; const showStatusInfo = (progressBarMode === 'all' ||