From 5f40cb6a008d29315bbc414f43c6e305044239cf Mon Sep 17 00:00:00 2001 From: 52191314 Date: Wed, 12 Aug 2026 10:48:01 +0700 Subject: [PATCH] feat(reader): swipe down to re-fetch a chapter, never cache empty responses - pull down at the top of a chapter to re-fetch it, recovering from transient empty responses without leaving the reader - empty responses are evicted from the chapter cache so a refresh re-fetches instead of replaying the empty message - add a refresh button to the reader's bottom bar and a 'Refresh chapter' link on the empty-chapter message - register the typescript-eslint plugin and ignore assets/ in the eslint flat config so lint-staged can lint core.js --- assets/reader/js/core.js | 9 ++++++++ eslint.config.js | 5 +++++ src/i18n/languages/en/strings.json | 2 +- .../reader/components/ReaderFooter.tsx | 10 ++++++++- .../reader/components/WebViewReader.tsx | 13 ++++++++++- .../reader/hooks/__tests__/useChapter.test.ts | 22 +++++++++++++++++++ src/screens/reader/hooks/useChapter.ts | 15 ++++++++++--- .../__tests__/sanitizeChapterText.test.ts | 14 ++++++++++++ .../reader/utils/sanitizeChapterText.ts | 7 ++++++ 9 files changed, 91 insertions(+), 6 deletions(-) diff --git a/assets/reader/js/core.js b/assets/reader/js/core.js index 463a9a4cd7..f038a5001d 100644 --- a/assets/reader/js/core.js +++ b/assets/reader/js/core.js @@ -907,6 +907,15 @@ window.addEventListener('load', () => { ) { return; } + if ( + diffY > 80 && + Math.abs(diffY) > Math.abs(diffX) * 2 && + window.scrollY <= 0 + ) { + e.preventDefault(); + reader.post({ type: 'refresh' }); + return; + } if ( reader.generalSettings.val.swipeGestures && Math.abs(diffX) > Math.abs(diffY) * 2 && diff --git a/eslint.config.js b/eslint.config.js index 0f106f2071..abe6efc5b7 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,6 +1,7 @@ const { defineConfig, globalIgnores } = require('eslint/config'); const expoConfig = require('eslint-config-expo/flat'); const { FlatCompat } = require('@eslint/eslintrc'); +const tseslint = require('@typescript-eslint/eslint-plugin'); const compat = new FlatCompat({ baseDirectory: __dirname, @@ -19,6 +20,7 @@ module.exports = defineConfig([ '.expo/**', 'dist/**', 'coverage/**', + 'assets/**', ]), expoConfig, ...compat @@ -30,6 +32,9 @@ module.exports = defineConfig([ { files: ['**/*.{js,jsx,ts,tsx}'], + plugins: { + '@typescript-eslint': tseslint, + }, rules: { 'no-shadow': 'off', 'no-undef': 'off', diff --git a/src/i18n/languages/en/strings.json b/src/i18n/languages/en/strings.json index fd5ea8b74d..2fe7731b4b 100644 --- a/src/i18n/languages/en/strings.json +++ b/src/i18n/languages/en/strings.json @@ -671,7 +671,7 @@ "scrollToCurrentChapter": "Scroll to current chapter", "scrollToTop": "Scroll to top" }, - "emptyChapterMessage": "

Chapter couldn't be loaded

No readable content was found. Check the chapter in WebView; if it loads there, report the plugin issue.

Plugin: %{pluginId}
Novel: %{novelName}
Chapter: %{chapterName}

", + "emptyChapterMessage": "

Chapter couldn't be loaded

No readable content was found. Check the chapter in WebView; if it loads there, report the plugin issue.

Refresh chapter

Plugin: %{pluginId}
Novel: %{novelName}
Chapter: %{chapterName}

", "finished": "Finished", "nextChapter": "Next: %{name}", "noNextChapter": "There's no next chapter", diff --git a/src/screens/reader/components/ReaderFooter.tsx b/src/screens/reader/components/ReaderFooter.tsx index 1c9c1c70d0..9847680ad8 100644 --- a/src/screens/reader/components/ReaderFooter.tsx +++ b/src/screens/reader/components/ReaderFooter.tsx @@ -66,7 +66,8 @@ const ChapterFooter = ({ scrollToStart, openDrawer, }: ChapterFooterProps) => { - const { nextChapter, prevChapter, navigateChapter } = useChapterContext(); + const { nextChapter, prevChapter, navigateChapter, refetch } = + useChapterContext(); const theme = useTheme(); const rippleConfig = { color: theme.rippleColor, @@ -124,6 +125,13 @@ const ChapterFooter = ({ iconColor={theme.onSurface} /> + refetch()} + > + + = ({ webViewRef, onUserInteraction, isTTSReadingRef, + refetch, } = useChapterContext(); const theme = useTheme(); const initialReaderSettings = useMemo( @@ -458,6 +462,10 @@ const WebViewReader: React.FC = ({ void Linking.openURL(url); return false; } + if (isChapterRefreshUrl(url)) { + refetch(); + return false; + } return true; }} onLoadEnd={() => { @@ -552,6 +560,9 @@ const WebViewReader: React.FC = ({ case 'hide': onPress(); break; + case 'refresh': + refetch(); + break; case 'next': nextChapterScreenVisible.current = true; if (event.autoStartTTS) { diff --git a/src/screens/reader/hooks/__tests__/useChapter.test.ts b/src/screens/reader/hooks/__tests__/useChapter.test.ts index f5bcd9b330..372967a3c6 100644 --- a/src/screens/reader/hooks/__tests__/useChapter.test.ts +++ b/src/screens/reader/hooks/__tests__/useChapter.test.ts @@ -352,6 +352,28 @@ describe('useChapter', () => { ); }); + it('drops an empty chapter from the cache so a refresh refetches', async () => { + const store = createStore(); + mockUseNovelActions.mockReturnValue(store.state); + mockFetchChapter.mockResolvedValueOnce(' '); + + const { result } = renderHook(() => useFlatChapter(initialChapter)); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.chapterText).toBe('SANITIZED: '); + expect(store.chapterTextCache.read(initialChapter.id)).toBeUndefined(); + + mockFetchChapter.mockResolvedValue('recovered body'); + await act(async () => { + result.current.refetch(); + }); + + await waitFor(() => + expect(result.current.chapterText).toBe('SANITIZED:recovered body'), + ); + expect(mockFetchChapter).toHaveBeenCalledTimes(2); + }); + it('reuses prefetched promise cache to avoid duplicate concurrent fetches for same chapter', async () => { const store = createStore(); mockUseNovelActions.mockReturnValue(store.state); diff --git a/src/screens/reader/hooks/useChapter.ts b/src/screens/reader/hooks/useChapter.ts index 1e6f5b52b3..ab9a8ba807 100644 --- a/src/screens/reader/hooks/useChapter.ts +++ b/src/screens/reader/hooks/useChapter.ts @@ -172,9 +172,18 @@ export default function useChapter( return cached; } - const pending = loadChapterText(chap).then(text => - sanitizeChapterText(novel.pluginId, novel.name, chap.name, text), - ); + const pending = loadChapterText(chap).then(text => { + const sanitized = sanitizeChapterText( + novel.pluginId, + novel.name, + chap.name, + text, + ); + if (!text.trim()) { + chapterTextCache.remove(chap.id); + } + return sanitized; + }); chapterTextCache.write(chap.id, pending); // Never keep a failed load in the cache, otherwise a retry would // resolve instantly with the same failure. diff --git a/src/screens/reader/utils/__tests__/sanitizeChapterText.test.ts b/src/screens/reader/utils/__tests__/sanitizeChapterText.test.ts index 01379f4d04..9a875e16e4 100644 --- a/src/screens/reader/utils/__tests__/sanitizeChapterText.test.ts +++ b/src/screens/reader/utils/__tests__/sanitizeChapterText.test.ts @@ -1,6 +1,8 @@ import { getString } from '@i18n/translations'; import { + CHAPTER_REFRESH_URL, + isChapterRefreshUrl, isPluginIssueReportUrl, sanitizeChapterText, } from '../sanitizeChapterText'; @@ -14,6 +16,7 @@ jest.mock('@i18n/translations', () => ({ novelName: string; chapterName: string; reportUrl: string; + refreshUrl: string; }, ) => [ @@ -21,6 +24,7 @@ jest.mock('@i18n/translations', () => ({ options.novelName, options.chapterName, options.reportUrl, + options.refreshUrl, ].join('|'), ), })); @@ -68,11 +72,13 @@ describe('sanitizeChapterText', () => { pluginId: 'plugin.test', novelName: 'A <Novel>', chapterName: 'Chapter 1 & "After"', + refreshUrl: CHAPTER_REFRESH_URL, }), ); expect(result).toContain( 'template=report_issue.yml&title=%5Bplugin.test%5D%20Empty%20chapter%3A%20A%20%3CNovel%3E%20%E2%80%94%20Chapter%201%20%26%20%22After%22', ); + expect(result).toContain(CHAPTER_REFRESH_URL); }); }); @@ -90,3 +96,11 @@ describe('isPluginIssueReportUrl', () => { ).toBe(false); }); }); + +describe('isChapterRefreshUrl', () => { + it('matches the refresh-chapter custom scheme', () => { + expect(isChapterRefreshUrl(CHAPTER_REFRESH_URL)).toBe(true); + expect(isChapterRefreshUrl('lnreader://refresh-chapter?x=1')).toBe(false); + expect(isChapterRefreshUrl('https://example.com')).toBe(false); + }); +}); diff --git a/src/screens/reader/utils/sanitizeChapterText.ts b/src/screens/reader/utils/sanitizeChapterText.ts index f135a32030..bc30c7dcc3 100644 --- a/src/screens/reader/utils/sanitizeChapterText.ts +++ b/src/screens/reader/utils/sanitizeChapterText.ts @@ -4,10 +4,16 @@ import sanitizeHtml from 'sanitize-html'; const PLUGIN_ISSUE_REPORT_URL = 'https://github.com/lnreader/lnreader-plugins/issues/new'; +/** Custom scheme intercepted by the reader WebView to re-fetch the chapter. */ +export const CHAPTER_REFRESH_URL = 'lnreader://refresh-chapter'; + export const isPluginIssueReportUrl = (url: string): boolean => url === PLUGIN_ISSUE_REPORT_URL || url.startsWith(`${PLUGIN_ISSUE_REPORT_URL}?`); +export const isChapterRefreshUrl = (url: string): boolean => + url === CHAPTER_REFRESH_URL; + const escapeHtml = (value: string): string => value.replace( /[&<>"']/g, @@ -112,6 +118,7 @@ export const sanitizeChapterText = ( reportUrl: escapeHtml( getPluginIssueReportUrl(pluginId, novelName, chapterName), ), + refreshUrl: escapeHtml(CHAPTER_REFRESH_URL), }) ); };