fix(reader): revert footer to default visibility when tap-to-toggle is disabled (#4065)

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) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-05-05 23:27:11 +08:00
committed by GitHub
parent 15c0a7a2f2
commit 06aec0b597
2 changed files with 135 additions and 0 deletions
@@ -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(
<ProgressBar
bookKey='book-1'
horizontalGap={0}
contentInsets={{ top: 0, right: 0, bottom: 0, left: 0 }}
gridInsets={{ top: 0, right: 0, bottom: 0, left: 0 }}
/>,
);
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);
});
});
@@ -146,6 +146,18 @@ const ProgressBar: React.FC<ProgressBarProps> = ({
// 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' ||