Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions assets/reader/js/core.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 &&
Expand Down
5 changes: 5 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -19,6 +20,7 @@ module.exports = defineConfig([
'.expo/**',
'dist/**',
'coverage/**',
'assets/**',
]),
expoConfig,
...compat
Expand All @@ -30,6 +32,9 @@ module.exports = defineConfig([

{
files: ['**/*.{js,jsx,ts,tsx}'],
plugins: {
'@typescript-eslint': tseslint,
},
rules: {
'no-shadow': 'off',
'no-undef': 'off',
Expand Down
2 changes: 1 addition & 1 deletion src/i18n/languages/en/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -671,7 +671,7 @@
"scrollToCurrentChapter": "Scroll to current chapter",
"scrollToTop": "Scroll to top"
},
"emptyChapterMessage": "<h2>Chapter couldn't be loaded</h2><p>No readable content was found. Check the chapter in WebView; if it loads there, <a href='%{reportUrl}'>report the plugin issue</a>.</p><p>Plugin: %{pluginId}<br>Novel: %{novelName}<br>Chapter: %{chapterName}</p>",
"emptyChapterMessage": "<h2>Chapter couldn't be loaded</h2><p>No readable content was found. Check the chapter in WebView; if it loads there, <a href='%{reportUrl}'>report the plugin issue</a>.</p><p><a href='%{refreshUrl}'>Refresh chapter</a></p><p>Plugin: %{pluginId}<br>Novel: %{novelName}<br>Chapter: %{chapterName}</p>",
"finished": "Finished",
"nextChapter": "Next: %{name}",
"noNextChapter": "There's no next chapter",
Expand Down
10 changes: 9 additions & 1 deletion src/screens/reader/components/ReaderFooter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -124,6 +125,13 @@ const ChapterFooter = ({
iconColor={theme.onSurface}
/>
</Pressable>
<Pressable
android_ripple={rippleConfig}
style={styles.buttonStyles}
onPress={() => refetch()}
>
<IconButton icon="refresh" size={26} iconColor={theme.onSurface} />
</Pressable>
<Pressable
android_ripple={rippleConfig}
style={styles.buttonStyles}
Expand Down
13 changes: 12 additions & 1 deletion src/screens/reader/components/WebViewReader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,10 @@ import { Dialog } from '@components/Dialog';
import { TextInput } from 'react-native-paper';
import useCustomCode from './Hooks/useCustomCode';
import useTextModifications from './Hooks/useTextModifications';
import { isPluginIssueReportUrl } from '../utils/sanitizeChapterText';
import {
isChapterRefreshUrl,
isPluginIssueReportUrl,
} from '../utils/sanitizeChapterText';

export type WebViewPostEvent = {
type: string;
Expand Down Expand Up @@ -138,6 +141,7 @@ const WebViewReader: React.FC<WebViewReaderProps> = ({
webViewRef,
onUserInteraction,
isTTSReadingRef,
refetch,
} = useChapterContext();
const theme = useTheme();
const initialReaderSettings = useMemo(
Expand Down Expand Up @@ -458,6 +462,10 @@ const WebViewReader: React.FC<WebViewReaderProps> = ({
void Linking.openURL(url);
return false;
}
if (isChapterRefreshUrl(url)) {
refetch();
return false;
}
return true;
}}
onLoadEnd={() => {
Expand Down Expand Up @@ -552,6 +560,9 @@ const WebViewReader: React.FC<WebViewReaderProps> = ({
case 'hide':
onPress();
break;
case 'refresh':
refetch();
break;
case 'next':
nextChapterScreenVisible.current = true;
if (event.autoStartTTS) {
Expand Down
22 changes: 22 additions & 0 deletions src/screens/reader/hooks/__tests__/useChapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
15 changes: 12 additions & 3 deletions src/screens/reader/hooks/useChapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
14 changes: 14 additions & 0 deletions src/screens/reader/utils/__tests__/sanitizeChapterText.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { getString } from '@i18n/translations';

import {
CHAPTER_REFRESH_URL,
isChapterRefreshUrl,
isPluginIssueReportUrl,
sanitizeChapterText,
} from '../sanitizeChapterText';
Expand All @@ -14,13 +16,15 @@ jest.mock('@i18n/translations', () => ({
novelName: string;
chapterName: string;
reportUrl: string;
refreshUrl: string;
},
) =>
[
options.pluginId,
options.novelName,
options.chapterName,
options.reportUrl,
options.refreshUrl,
].join('|'),
),
}));
Expand Down Expand Up @@ -68,11 +72,13 @@ describe('sanitizeChapterText', () => {
pluginId: 'plugin.test',
novelName: 'A &lt;Novel&gt;',
chapterName: 'Chapter 1 &amp; &quot;After&quot;',
refreshUrl: CHAPTER_REFRESH_URL,
}),
);
expect(result).toContain(
'template=report_issue.yml&amp;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);
});
});

Expand All @@ -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);
});
});
7 changes: 7 additions & 0 deletions src/screens/reader/utils/sanitizeChapterText.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -112,6 +118,7 @@ export const sanitizeChapterText = (
reportUrl: escapeHtml(
getPluginIssueReportUrl(pluginId, novelName, chapterName),
),
refreshUrl: escapeHtml(CHAPTER_REFRESH_URL),
})
);
};
Loading