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),
})
);
};