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:
@@ -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' ||
|
||||
|
||||
Reference in New Issue
Block a user