feat: Add URL Novel Search and Intent-Based Novel Opening (#1987)

* Add native share intent handling module

* Handle shared URLs via intent handler

* i18n: add global search and novel error strings

Generated with omp (AI coding agent)

* novel store: track bootstrap failures as user-facing errors

Bootstrap failures no longer surface raw plugin/DB errors; missing novels map to a custom not-found message. Generated with omp (AI coding agent)

* novel screen: render error view when bootstrap fails

Shows a custom error with a back action instead of a blank screen, and hides the appbar actions in the error state. Generated with omp (AI coding agent)

* global search: never auto-navigate or search URL text

URLs are handled by the screen's explicit submit flow; navigation and search stay out of the hook. Generated with omp (AI coding agent)

* global search: add animated open-novel FAB with clipboard offer

Submit or FAB tap opens a matching URL's novel; unmatched URLs toast. The FAB floats above the keyboard via keyboard-controller and slides in from the right. Generated with omp (AI coding agent)

* fix review issues

* fix rebase issue
This commit is contained in:
Patrick Loser
2026-09-01 15:29:32 +02:00
committed by GitHub
parent 4b580c90ef
commit 77be5624f2
27 changed files with 1508 additions and 59 deletions
+7
View File
@@ -70,6 +70,13 @@
"android.permission.FOREGROUND_SERVICE_DATA_SYNC",
"android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK",
"android.permission.POST_NOTIFICATIONS"
],
"intentFilters": [
{
"action": "SEND",
"category": ["DEFAULT"],
"data": [{ "mimeType": "text/plain" }]
}
]
},
"extra": {
@@ -0,0 +1,18 @@
plugins {
id 'com.android.library'
id 'expo-module-gradle-plugin'
}
group = 'expo.modules.nativesharereceiver'
version = '0.1.0'
android {
namespace "expo.modules.nativesharereceiver"
defaultConfig {
versionCode 1
versionName "0.1.0"
}
lintOptions {
abortOnError false
}
}
@@ -0,0 +1,37 @@
package expo.modules.nativesharereceiver
import android.content.Intent
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
class NativeShareReceiverModule : Module() {
override fun definition() = ModuleDefinition {
Name("NativeShareReceiver")
Events("SharedText")
Function("getInitialSharedText") {
val intent = appContext.currentActivity?.intent
val text = extractSharedText(intent)
if (text != null) {
// Consume the share so JS reloads (Fast Refresh, OTA) that re-read
// the activity intent don't re-deliver the same text.
intent?.removeExtra(Intent.EXTRA_TEXT)
}
text
}
OnNewIntent { intent ->
extractSharedText(intent)?.let { text ->
sendEvent("SharedText", mapOf("text" to text))
}
}
}
private fun extractSharedText(intent: Intent?): String? {
if (intent?.action != Intent.ACTION_SEND) {
return null
}
return intent.getStringExtra(Intent.EXTRA_TEXT)
}
}
@@ -0,0 +1,6 @@
{
"platforms": ["android"],
"android": {
"modules": ["expo.modules.nativesharereceiver.NativeShareReceiverModule"]
}
}
+2
View File
@@ -0,0 +1,2 @@
import NativeShareReceiver from './src/NativeShareReceiverModule';
export default NativeShareReceiver;
@@ -0,0 +1,3 @@
{
"name": "native-share-receiver"
}
@@ -0,0 +1,13 @@
import { NativeModule, requireNativeModule } from 'expo-modules-core';
export type NativeShareReceiverEvents = {
SharedText: (payload: { text: string }) => void;
};
declare class NativeShareReceiverModule extends NativeModule<NativeShareReceiverEvents> {
getInitialSharedText(): string | null;
}
export default requireNativeModule<NativeShareReceiverModule>(
'NativeShareReceiver',
);
@@ -0,0 +1,155 @@
import { NovelInfo } from '@database/types';
import { getString } from '@i18n/translations';
import { createNovelStoreActions } from '../store/novelStore.actions';
interface BootstrapServiceSlice {
bootstrapNovelAsync: jest.Mock;
bootstrapNovelSync: jest.Mock;
getNextChapterBatch: jest.Mock;
loadUpToBatch: jest.Mock;
}
const mockNovel: NovelInfo = {
id: 1,
path: '/novels/x',
name: 'X',
pluginId: 'plugin.test',
};
interface TestState {
novel: NovelInfo | undefined;
novelPath: string;
pluginId: string;
pageIndex: number;
pages: string[];
novelSettings: { filter: never[]; excludedScanlators: never[] };
loading: boolean;
fetching: boolean;
error: string | undefined;
}
const createHarness = (overrides: Partial<TestState> = {}) => {
let state: TestState = {
novel: undefined,
novelPath: '/x',
pluginId: 'plugin.test',
pageIndex: 0,
pages: [],
novelSettings: { filter: [], excludedScanlators: [] },
loading: false,
fetching: false,
error: undefined,
...overrides,
};
const set = jest.fn(
(partial: Partial<TestState> | ((s: TestState) => Partial<TestState>)) => {
const patch = typeof partial === 'function' ? partial(state) : partial;
state = { ...state, ...patch };
},
);
const get = () => state;
const bootstrapService: jest.Mocked<BootstrapServiceSlice> = {
bootstrapNovelAsync: jest.fn(),
bootstrapNovelSync: jest.fn(),
getNextChapterBatch: jest.fn(),
loadUpToBatch: jest.fn(),
};
let requestVersion = 0;
const chapterRequestCoordinator = {
current: jest.fn(() => requestVersion),
invalidate: jest.fn(() => ++requestVersion),
};
const transformChapters = jest.fn(chs => chs);
const actions = createNovelStoreActions({
//@ts-expect-error partial state/actions for testing
set, //@ts-expect-error
get,
deps: {
bootstrapService,
chapterActionsDependencies: {} as never,
chapterRequestCoordinator,
transformChapters,
} as never,
defaultChapterSort: 'positionAsc',
});
return {
actions,
getState: () => state,
bootstrapService,
chapterRequestCoordinator,
};
};
describe('novelStore.bootstrap', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('maps a missing novel to the not-found error', async () => {
const harness = createHarness();
harness.bootstrapService.bootstrapNovelAsync.mockResolvedValue({
ok: false,
reason: 'missing-novel',
});
const ok = await harness.actions.bootstrapNovel();
expect(ok).toBe(false);
expect(harness.getState().error).toBe(getString('novelScreen.notFound'));
expect(harness.getState().loading).toBe(false);
});
it('maps a bootstrap error to the load-failed message, never the raw error', async () => {
const harness = createHarness();
harness.bootstrapService.bootstrapNovelAsync.mockResolvedValue({
ok: false,
reason: 'error',
error: new Error('boom'),
});
const ok = await harness.actions.bootstrapNovel();
expect(ok).toBe(false);
expect(harness.getState().error).toBe(getString('novelScreen.loadFailed'));
expect(harness.getState().error).not.toBe('boom');
});
it('maps a missing-chapters failure to the unable-to-get-novel message', async () => {
const harness = createHarness();
harness.bootstrapService.bootstrapNovelAsync.mockResolvedValue({
ok: false,
reason: 'missing-chapters',
});
const ok = await harness.actions.bootstrapNovel();
expect(ok).toBe(false);
expect(harness.getState().error).toBe(
getString('updatesScreen.unableToGetNovel'),
);
expect(harness.getState().loading).toBe(false);
});
it('clears the error on a successful bootstrap', async () => {
const harness = createHarness({ error: 'previous error' });
harness.bootstrapService.bootstrapNovelAsync.mockResolvedValue({
ok: true,
novel: mockNovel,
pages: ['1'],
pageIndex: 0,
scanlators: [],
chapters: [],
batchInformation: { batch: 0, total: 0, totalChapters: 0 },
firstUnreadChapter: undefined,
});
const ok = await harness.actions.bootstrapNovel();
expect(ok).toBe(true);
expect(harness.getState().error).toBeUndefined();
expect(harness.getState().novel).toBe(mockNovel);
});
});
@@ -1,5 +1,7 @@
import { ChapterFilterKey, ChapterOrderKey } from '@database/constants';
import { getString } from '@i18n/translations';
import { NovelSettings } from '../types';
import { BootstrapFailureResult } from '../store-helper/bootstrapService';
import {
GetState,
NovelStoreDependencies,
@@ -14,6 +16,17 @@ interface CreateNovelStoreActionsParams {
defaultChapterSort: ChapterOrderKey;
}
const resolveBootstrapError = (result: BootstrapFailureResult): string => {
if (result.reason === 'missing-chapters') {
return getString('updatesScreen.unableToGetNovel');
}
if (result.reason === 'error') {
// A real fetch/parse failure — don't claim the novel is missing.
return getString('novelScreen.loadFailed');
}
return getString('novelScreen.notFound');
};
export const createNovelStoreActions = ({
set,
get,
@@ -42,7 +55,7 @@ export const createNovelStoreActions = ({
const requestId = deps.chapterRequestCoordinator.invalidate();
inflightBootstrap = (async () => {
set({ loading: true, fetching: true });
set({ loading: true, fetching: true, error: undefined });
const state = get();
const result = await deps.bootstrapService.bootstrapNovelAsync({
@@ -63,6 +76,7 @@ export const createNovelStoreActions = ({
set({
loading: false,
fetching: false,
error: resolveBootstrapError(result),
});
return false;
}
@@ -23,6 +23,7 @@ export const createNovelSlice = ({
return {
loading: false,
fetching: false,
error: undefined,
pluginId,
novelPath,
novel,
@@ -23,6 +23,7 @@ export interface ChapterSliceState {
export interface NovelStoreData extends ChapterSliceState {
loading: boolean;
fetching: boolean;
error: string | undefined;
pluginId: string;
novelPath: string;
+7 -2
View File
@@ -450,7 +450,10 @@
},
"globalSearch": {
"allSources": "all sources",
"searchIn": "Search a novel in"
"searchIn": "Search a novel in",
"openNovel": "Open novel",
"openCopiedNovel": "Open copied novel",
"noSourceForUrl": "No source found for this URL"
},
"history": "History",
"historyScreen": {
@@ -600,9 +603,11 @@
"searchByName": "Search by Chapter Name",
"searchByNameDescription": "Turn off to search by chapter number."
},
"loadFailed": "Unable to load novel",
"migrate": "Migrate",
"noSummary": "No summary",
"noCoverFound": "No cover found",
"notFound": "Novel not found on the source",
"progress": "Progress %{progress} %",
"readChaptersDeleted": "Read chapters deleted",
"startReadingChapters": "Start reading %{name}",
@@ -789,7 +794,7 @@
"other": "%{count} days"
}
},
"customCodeSettings": {
"customCodeSettings": {
"textManipulation": "Text Manipulation",
"codeSnippets": "Code Snippets",
"cssSnippets": "CSS Snippets",
+5
View File
@@ -394,6 +394,9 @@ export interface StringMap {
'generalSettingsScreen.inactivityTimeoutDesc': 'string';
'globalSearch.allSources': 'string';
'globalSearch.searchIn': 'string';
'globalSearch.openNovel': 'string';
'globalSearch.openCopiedNovel': 'string';
'globalSearch.noSourceForUrl': 'string';
'history': 'string';
'historyScreen.chapter': 'string';
'historyScreen.clearHistorWarning': 'string';
@@ -505,9 +508,11 @@ export interface StringMap {
'novelScreen.jumpToChapterModal.openChapterDescription': 'string';
'novelScreen.jumpToChapterModal.searchByName': 'string';
'novelScreen.jumpToChapterModal.searchByNameDescription': 'string';
'novelScreen.loadFailed': 'string';
'novelScreen.migrate': 'string';
'novelScreen.noSummary': 'string';
'novelScreen.noCoverFound': 'string';
'novelScreen.notFound': 'string';
'novelScreen.progress': 'string';
'novelScreen.readChaptersDeleted': 'string';
'novelScreen.startReadingChapters': 'string';
+7 -3
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef } from 'react';
import { useEffect } from 'react';
import { DefaultTheme, NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
@@ -36,6 +36,10 @@ import { useMMKVBoolean } from 'react-native-mmkv';
import OnboardingScreen from '@screens/onboarding/OnboardingScreen';
import { backgroundTasks } from '@services/backgroundTasks';
import ReaderStack from './ReaderStack';
import ShareIntentHandler, {
flushPendingShare,
navigationRef,
} from './ShareIntentHandler';
import { LibraryContextProvider } from '@components/Context/LibraryContext';
import { UpdateContextProvider } from '@components/Context/UpdateContext';
import { useReactNavigationDevTools } from '@rozenite/react-navigation-plugin';
@@ -68,8 +72,6 @@ const MainNavigator = () => {
}
}, [isOnboarded, refreshPlugins, updateLibraryOnLaunch]);
const navigationRef = useRef(null);
// Enable React Navigation DevTools in development
useReactNavigationDevTools({ ref: navigationRef });
@@ -79,6 +81,7 @@ const MainNavigator = () => {
return (
<NavigationContainer<RootStackParamList>
ref={navigationRef}
onReady={flushPendingShare}
theme={{
colors: {
...DefaultTheme.colors,
@@ -108,6 +111,7 @@ const MainNavigator = () => {
},
}}
>
<ShareIntentHandler />
<LibraryContextProvider>
<UpdateContextProvider>
<AppUpdateChecker />
+84
View File
@@ -0,0 +1,84 @@
import { useCallback, useEffect } from 'react';
import { createNavigationContainerRef } from '@react-navigation/native';
import { useEventListener } from 'expo';
import NativeShareReceiver from '@modules/native-share-receiver';
import { resolveSharedUrl } from '@services/share/resolveSharedUrl';
import { RootStackParamList } from './types';
export const navigationRef = createNavigationContainerRef<RootStackParamList>();
// The container resolves its initial state asynchronously (linking), so a
// share arriving before navigationRef.isReady() would otherwise be dropped.
// Queue it here; Main.tsx flushes once NavigationContainer's onReady fires.
let pendingShare: string | null = null;
const dispatchSharedText = (text: string) => {
const result = resolveSharedUrl(text);
if (!result) {
return;
}
if (!navigationRef.isReady()) {
pendingShare = text;
return;
}
if (result.kind === 'novel') {
// Novel screen bootstraps itself via fetchNovel(pluginId, path);
// name/cover params are not consumed by the novel store (verified in
// NovelContextProvider: only path/pluginId are read).
navigationRef.navigate('ReaderStack', {
screen: 'Novel',
params: {
name: '',
path: result.path,
pluginId: result.pluginId,
cover: null,
},
});
} else {
navigationRef.navigate('GlobalSearchScreen', {
searchText: result.searchText,
});
}
};
export const flushPendingShare = () => {
if (pendingShare === null || !navigationRef.isReady()) {
return;
}
const text = pendingShare;
pendingShare = null;
dispatchSharedText(text);
};
const ShareIntentHandler = () => {
const handleSharedText = useCallback((text: string) => {
dispatchSharedText(text);
}, []);
useEffect(() => {
let active = true;
Promise.resolve(NativeShareReceiver.getInitialSharedText())
.then(text => {
if (active && text) {
handleSharedText(text);
}
})
.catch(() => {
// Nothing to act on if the initial intent cannot be read.
});
return () => {
active = false;
};
}, [handleSharedText]);
useEventListener(NativeShareReceiver, 'SharedText', ({ text }) => {
if (text) {
handleSharedText(text);
}
});
return null;
};
export default ShareIntentHandler;
@@ -0,0 +1,224 @@
import type { ReactNode } from 'react';
import { act, render, waitFor } from '@test-utils';
import NativeShareReceiver from '@modules/native-share-receiver';
import { getMMKVObject } from '@utils/mmkv/mmkv';
import type { PluginItem } from '@plugins/types';
import ShareIntentHandler, {
flushPendingShare,
navigationRef,
} from '../ShareIntentHandler';
jest.mock('@hooks/persisted/useTheme', () => ({
ThemeProvider: ({ children }: { children: ReactNode }) => children,
useTheme: () => ({}),
}));
jest.mock('@hooks/persisted', () => ({
useTheme: () => ({}),
}));
jest.mock('@components/AppErrorBoundary/AppErrorBoundary', () => ({
__esModule: true,
default: ({ children }: { children: ReactNode }) => children,
}));
jest.mock('react-native-safe-area-context', () => {
const ReactModule = require('react');
const { View } = require('react-native');
const frame = { height: 800, width: 400, x: 0, y: 0 };
const insets = { bottom: 0, left: 0, right: 0, top: 0 };
return {
SafeAreaFrameContext: ReactModule.createContext(frame),
SafeAreaInsetsContext: ReactModule.createContext(insets),
SafeAreaProvider: ({ children }: { children: ReactNode }) => children,
SafeAreaView: ({ children, ...props }: { children: ReactNode }) =>
ReactModule.createElement(View, props, children),
initialWindowMetrics: { frame, insets },
useSafeAreaFrame: () => frame,
useSafeAreaInsets: () => insets,
};
});
jest.mock('@gorhom/bottom-sheet', () => ({
BottomSheetModalProvider: ({ children }: { children: ReactNode }) => children,
}));
jest.mock('@screens/novel/NovelContext', () => ({
NovelContextProvider: ({ children }: { children: ReactNode }) => children,
}));
jest.mock('@plugins/pluginManager', () => ({
INSTALLED_PLUGINS_KEY: 'INSTALL_PLUGINS',
}));
jest.mock('@utils/mmkv/mmkv', () => ({
getMMKVObject: jest.fn(),
setMMKVObject: jest.fn(),
}));
const testSource: PluginItem = {
id: 'test-source',
name: 'Test Source',
site: 'https://testsource.example.com/',
lang: 'English',
version: '1.0.0',
url: 'https://example.com/test-source.js',
iconUrl: 'https://example.com/test-source.png',
};
const mockGetMMKVObject = getMMKVObject as jest.Mock;
const mockNavigate = navigationRef.navigate as jest.Mock;
const NOVEL_URL =
'https://testsource.example.com/fiction/21220/mother-of-learning';
beforeEach(() => {
jest.clearAllMocks();
mockGetMMKVObject.mockReturnValue([testSource]);
(NativeShareReceiver.getInitialSharedText as jest.Mock).mockResolvedValue(
null,
);
(navigationRef.isReady as jest.Mock).mockReturnValue(true);
});
describe('ShareIntentHandler', () => {
it('navigates to the novel on an initial share matching a plugin', async () => {
(NativeShareReceiver.getInitialSharedText as jest.Mock).mockResolvedValue(
NOVEL_URL,
);
render(<ShareIntentHandler />);
await waitFor(() =>
expect(mockNavigate).toHaveBeenCalledWith('ReaderStack', {
screen: 'Novel',
params: {
name: '',
path: 'fiction/21220/mother-of-learning',
pluginId: 'test-source',
cover: null,
},
}),
);
});
it('navigates to global search on an initial share matching no plugin', async () => {
(NativeShareReceiver.getInitialSharedText as jest.Mock).mockResolvedValue(
'https://www.someothersite.com/novel/1',
);
render(<ShareIntentHandler />);
await waitFor(() =>
expect(mockNavigate).toHaveBeenCalledWith('GlobalSearchScreen', {
searchText: 'https://www.someothersite.com/novel/1',
}),
);
});
it('does not navigate when the initial share is not a URL', async () => {
(NativeShareReceiver.getInitialSharedText as jest.Mock).mockResolvedValue(
'hello world',
);
render(<ShareIntentHandler />);
// Let the initial-share promise settle before asserting no navigation.
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(mockNavigate).not.toHaveBeenCalled();
});
it('navigates on a warm share delivered through the SharedText event', async () => {
render(<ShareIntentHandler />);
const [eventName, listener] = (NativeShareReceiver.addListener as jest.Mock)
.mock.calls[0];
expect(eventName).toBe('SharedText');
act(() => listener({ text: NOVEL_URL }));
expect(mockNavigate).toHaveBeenCalledWith('ReaderStack', {
screen: 'Novel',
params: {
name: '',
path: 'fiction/21220/mother-of-learning',
pluginId: 'test-source',
cover: null,
},
});
});
it('queues the initial share until the container is ready', async () => {
(NativeShareReceiver.getInitialSharedText as jest.Mock).mockResolvedValue(
NOVEL_URL,
);
(navigationRef.isReady as jest.Mock).mockReturnValue(false);
render(<ShareIntentHandler />);
// Let the initial-share promise settle while the container is not ready.
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(mockNavigate).not.toHaveBeenCalled();
(navigationRef.isReady as jest.Mock).mockReturnValue(true);
act(() => flushPendingShare());
expect(mockNavigate).toHaveBeenCalledWith('ReaderStack', {
screen: 'Novel',
params: {
name: '',
path: 'fiction/21220/mother-of-learning',
pluginId: 'test-source',
cover: null,
},
});
});
it('queues a warm share delivered before the container is ready', () => {
(navigationRef.isReady as jest.Mock).mockReturnValue(false);
render(<ShareIntentHandler />);
const [eventName, listener] = (NativeShareReceiver.addListener as jest.Mock)
.mock.calls[0];
expect(eventName).toBe('SharedText');
act(() => listener({ text: NOVEL_URL }));
expect(mockNavigate).not.toHaveBeenCalled();
(navigationRef.isReady as jest.Mock).mockReturnValue(true);
act(() => flushPendingShare());
expect(mockNavigate).toHaveBeenCalledWith('ReaderStack', {
screen: 'Novel',
params: {
name: '',
path: 'fiction/21220/mother-of-learning',
pluginId: 'test-source',
cover: null,
},
});
});
it('removes the SharedText listener on unmount', () => {
const { unmount } = render(<ShareIntentHandler />);
const subscription = (NativeShareReceiver.addListener as jest.Mock).mock
.results[0].value;
expect(subscription.remove).toBeDefined();
unmount();
expect(subscription.remove).toHaveBeenCalled();
});
});
@@ -1,6 +1,15 @@
import { useState } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { StyleSheet, View } from 'react-native';
import { ProgressBar } from 'react-native-paper';
import { FAB, ProgressBar } from 'react-native-paper';
import {
SlideInRight,
SlideOutRight,
createAnimatedComponent,
useAnimatedStyle,
} from 'react-native-reanimated';
import { getStringAsync } from 'expo-clipboard';
import { useFocusEffect } from '@react-navigation/native';
import { useReanimatedKeyboardAnimation } from 'react-native-keyboard-controller';
import {
EmptyView,
@@ -14,8 +23,13 @@ import { useSearch } from '@hooks';
import { useTheme } from '@hooks/persisted';
import { getString } from '@i18n/translations';
import { navigationRef } from '@navigators/ShareIntentHandler';
import { resolveSharedUrl } from '@services/share/resolveSharedUrl';
import { showToast } from '@utils/showToast';
import { useGlobalSearch } from './hooks/useGlobalSearch';
const AnimatedFAB = createAnimatedComponent(FAB);
interface Props {
route?: {
params?: {
@@ -33,12 +47,89 @@ const GlobalSearchScreen = (props: Props) => {
const onChangeText = (text: string) => setSearchText(text);
const [hasResultsOnly, setHasResultsOnly] = useState(false);
const [clipboardNovel, setClipboardNovel] = useState<
{ pluginId: string; path: string } | undefined
>();
// Edge-to-edge: the IME overlays the screen, so the FAB must float above it.
// Shared values track the keyboard frame-by-frame, so the FAB moves with it.
const { height: keyboardHeight } = useReanimatedKeyboardAnimation();
const fabPositionStyle = useAnimatedStyle(() => ({
// The library reports the keyboard height as a negative value (the
// translateY convention), so negate it for bottom positioning.
bottom: Math.max(16, -keyboardHeight.value),
}));
// Only a URL matching an installed source is offered from the clipboard.
useFocusEffect(
useCallback(() => {
let active = true;
getStringAsync()
.then(text => {
const result = text ? resolveSharedUrl(text) : undefined;
if (active) {
setClipboardNovel(
result?.kind === 'novel'
? { pluginId: result.pluginId, path: result.path }
: undefined,
);
}
})
.catch(() => {
// Clipboard unreadable — nothing to offer.
});
return () => {
active = false;
};
}, []),
);
const { searchResults, progress } = useGlobalSearch({
defaultSearchText: searchText,
hasResultsOnly,
});
const searchUrlResult = useMemo(
() => resolveSharedUrl(searchText),
[searchText],
);
const openNovel = useCallback((novel: { pluginId: string; path: string }) => {
navigationRef.navigate('ReaderStack', {
screen: 'Novel',
params: {
name: '',
path: novel.path,
pluginId: novel.pluginId,
cover: null,
},
});
}, []);
const handleSubmit = useCallback(() => {
if (searchUrlResult?.kind === 'novel') {
openNovel(searchUrlResult);
} else if (searchUrlResult) {
// Valid URL but no matching installed source.
showToast(getString('globalSearch.noSourceForUrl'));
}
// Non-URL text: the debounced search is already running; enter does nothing extra.
}, [openNovel, searchUrlResult]);
const openNovelOffer =
searchUrlResult?.kind === 'novel'
? {
icon: 'book-open-page-variant',
label: getString('globalSearch.openNovel'),
onPress: handleSubmit,
}
: searchText === '' && clipboardNovel
? {
icon: 'content-paste',
label: getString('globalSearch.openCopiedNovel'),
onPress: () => openNovel(clipboardNovel),
}
: null;
return (
<SafeAreaView>
<SearchbarV2
@@ -46,6 +137,7 @@ const GlobalSearchScreen = (props: Props) => {
placeholder={getString('browseScreen.globalSearch')}
leftIcon="magnify"
onChangeText={onChangeText}
onSubmitEditing={handleSubmit}
clearSearchbar={clearSearchbar}
theme={theme}
/>
@@ -80,6 +172,23 @@ const GlobalSearchScreen = (props: Props) => {
/>
}
/>
{openNovelOffer ? (
<AnimatedFAB
entering={SlideInRight.duration(250)}
exiting={SlideOutRight.duration(250)}
style={[
styles.openNovelFabContainer,
fabPositionStyle,
{ backgroundColor: theme.primary },
]}
testID="open-novel-button"
icon={openNovelOffer.icon}
label={openNovelOffer.label}
uppercase={false}
color={theme.onPrimary}
onPress={openNovelOffer.onPress}
/>
) : null}
</SafeAreaView>
);
};
@@ -87,6 +196,12 @@ const GlobalSearchScreen = (props: Props) => {
export default GlobalSearchScreen;
const styles = StyleSheet.create({
openNovelFabContainer: {
position: 'absolute',
right: 0,
bottom: 16,
margin: 16,
},
filterContainer: {
paddingHorizontal: 8,
paddingTop: 16,
@@ -0,0 +1,304 @@
import type { ReactNode } from 'react';
import { fireEvent, render, screen, waitFor } from '@test-utils';
import { getMMKVObject } from '@utils/mmkv/mmkv';
import { getStringAsync } from 'expo-clipboard';
import {
createNavigationContainerRef,
useFocusEffect,
} from '@react-navigation/native';
import { showToast } from '@utils/showToast';
import type { PluginItem } from '@plugins/types';
import GlobalSearchScreen from '../GlobalSearchScreen';
jest.mock('react-native-keyboard-controller', () => ({
useReanimatedKeyboardAnimation: () => ({
height: { value: 0 },
progress: { value: 0 },
}),
}));
jest.mock('@react-navigation/native', () => {
const mockNavigate = jest.fn();
return {
createNavigationContainerRef: () => ({
isReady: jest.fn(() => true),
navigate: mockNavigate,
getCurrentRoute: jest.fn(() => null),
}),
useFocusEffect: jest.fn(),
useNavigation: () => ({
navigate: mockNavigate,
setOptions: jest.fn(),
addListener: jest.fn(() => () => {}),
}),
};
});
const mockNavigationRef = createNavigationContainerRef();
const mockNavigate = mockNavigationRef.navigate as jest.Mock;
jest.mock('@components/index', () => {
const { createElement } = require('react');
const { Text, View, TextInput } = require('react-native');
return {
EmptyView: ({ description }: { description: string }) =>
createElement(Text, null, description),
SafeAreaView: ({ children }: { children: ReactNode }) =>
createElement(View, null, children),
SearchbarV2: ({
searchText,
onChangeText,
onSubmitEditing,
}: {
searchText: string;
onChangeText: (text: string) => void;
onSubmitEditing: () => void;
}) =>
createElement(TextInput, {
testID: 'search-input',
value: searchText,
onChangeText,
onSubmitEditing,
}),
SelectableChip: () => null,
};
});
jest.mock('react-native-paper', () => {
const { createElement, Fragment } = require('react');
const { Pressable, Text } = require('react-native');
return {
Provider: ({ children }: { children: ReactNode }) =>
createElement(Fragment, null, children),
ProgressBar: () => null,
FAB: ({
label,
onPress,
testID,
}: {
label: string;
onPress: () => void;
testID?: string;
}) =>
createElement(
Pressable,
{ testID, onPress },
createElement(Text, null, label),
),
};
});
jest.mock('expo-clipboard', () => ({
__esModule: true,
getStringAsync: jest.fn(async () => ''),
setStringAsync: jest.fn(async () => {}),
}));
jest.mock('@hooks', () => {
const { useCallback, useEffect, useMemo, useState } = require('react');
return {
useSearch: (defaultSearchText?: string, clearSearchOnUnfocus = true) => {
const [searchText, setSearchText] = useState(defaultSearchText || '');
const clearSearchbar = useCallback(() => setSearchText(''), []);
useEffect(() => {
if (!clearSearchOnUnfocus) {
return;
}
return require('@react-navigation/native')
.useNavigation()
.addListener('blur', clearSearchbar);
}, [clearSearchbar, clearSearchOnUnfocus]);
return useMemo(
() => ({ searchText, setSearchText, clearSearchbar }),
[searchText, setSearchText, clearSearchbar],
);
},
};
});
jest.mock('@hooks/persisted', () => ({
useTheme: () => ({}),
}));
jest.mock('@hooks/persisted/useTheme', () => ({
ThemeProvider: ({ children }: { children: ReactNode }) => children,
useTheme: () => ({}),
}));
jest.mock('@components/AppErrorBoundary/AppErrorBoundary', () => ({
__esModule: true,
default: ({ children }: { children: ReactNode }) => children,
}));
jest.mock('react-native-safe-area-context', () => {
const ReactModule = require('react');
const { View } = require('react-native');
const frame = { height: 800, width: 400, x: 0, y: 0 };
const insets = { bottom: 0, left: 0, right: 0, top: 0 };
return {
SafeAreaFrameContext: ReactModule.createContext(frame),
SafeAreaInsetsContext: ReactModule.createContext(insets),
SafeAreaProvider: ({ children }: { children: ReactNode }) => children,
SafeAreaView: ({ children, ...props }: { children: ReactNode }) =>
ReactModule.createElement(View, props, children),
initialWindowMetrics: { frame, insets },
useSafeAreaFrame: () => frame,
useSafeAreaInsets: () => insets,
};
});
jest.mock('@gorhom/bottom-sheet', () => ({
BottomSheetModalProvider: ({ children }: { children: ReactNode }) => children,
}));
jest.mock('@screens/novel/NovelContext', () => ({
NovelContextProvider: ({ children }: { children: ReactNode }) => children,
}));
jest.mock('@i18n/translations', () => ({
getString: (key: string) => key,
}));
jest.mock('@utils/showToast', () => ({
showToast: jest.fn(),
}));
jest.mock('../hooks/useGlobalSearch', () => ({
useGlobalSearch: () => ({ searchResults: [], progress: 0 }),
}));
jest.mock('../components/GlobalSearchResultsList', () => () => null);
jest.mock('@plugins/pluginManager', () => ({
INSTALLED_PLUGINS_KEY: 'INSTALL_PLUGINS',
}));
jest.mock('@utils/mmkv/mmkv', () => ({
getMMKVObject: jest.fn(),
setMMKVObject: jest.fn(),
}));
const testSource: PluginItem = {
id: 'test-source',
name: 'Test Source',
site: 'https://testsource.example.com/',
lang: 'English',
version: '1.0.0',
url: 'https://example.com/test-source.js',
iconUrl: 'https://example.com/test-source.png',
};
const NOVEL_URL =
'https://testsource.example.com/fiction/21220/mother-of-learning';
const mockGetMMKVObject = getMMKVObject as jest.Mock;
const mockShowToast = showToast as jest.Mock;
beforeEach(() => {
jest.clearAllMocks();
mockGetMMKVObject.mockReturnValue([testSource]);
(getStringAsync as jest.Mock).mockResolvedValue('');
(useFocusEffect as jest.Mock).mockReset();
});
describe('GlobalSearchScreen', () => {
it('shows the open-novel button for a matching URL and navigates on press', () => {
render(<GlobalSearchScreen />);
fireEvent.changeText(screen.getByTestId('search-input'), NOVEL_URL);
expect(screen.getByTestId('open-novel-button')).toBeTruthy();
fireEvent.press(screen.getByText('globalSearch.openNovel'));
expect(mockNavigate).toHaveBeenCalledWith('ReaderStack', {
screen: 'Novel',
params: {
name: '',
path: 'fiction/21220/mother-of-learning',
pluginId: 'test-source',
cover: null,
},
});
});
it('navigates on keyboard submit for a matching URL', () => {
render(<GlobalSearchScreen />);
fireEvent.changeText(screen.getByTestId('search-input'), NOVEL_URL);
fireEvent(screen.getByTestId('search-input'), 'submitEditing');
expect(mockNavigate).toHaveBeenCalledWith('ReaderStack', {
screen: 'Novel',
params: {
name: '',
path: 'fiction/21220/mother-of-learning',
pluginId: 'test-source',
cover: null,
},
});
});
it('offers no open-novel button and toasts when the URL matches no installed source', () => {
mockGetMMKVObject.mockReturnValue([]);
render(<GlobalSearchScreen />);
fireEvent.changeText(
screen.getByTestId('search-input'),
'https://www.someothersite.com/novel/1',
);
// An unmatched URL must not be advertised as an openable novel.
expect(screen.queryByTestId('open-novel-button')).toBeNull();
fireEvent(screen.getByTestId('search-input'), 'submitEditing');
expect(mockShowToast).toHaveBeenCalledWith('globalSearch.noSourceForUrl');
expect(mockNavigate).not.toHaveBeenCalled();
});
it('offers the clipboard novel while the search bar is empty', async () => {
(useFocusEffect as jest.Mock).mockImplementation(cb => {
cb();
});
(getStringAsync as jest.Mock).mockResolvedValue(NOVEL_URL);
render(<GlobalSearchScreen />);
expect(await screen.findByTestId('open-novel-button')).toBeTruthy();
fireEvent.press(screen.getByText('globalSearch.openCopiedNovel'));
expect(mockNavigate).toHaveBeenCalledWith('ReaderStack', {
screen: 'Novel',
params: {
name: '',
path: 'fiction/21220/mother-of-learning',
pluginId: 'test-source',
cover: null,
},
});
});
it('offers nothing when the clipboard holds an unmatched URL', async () => {
(useFocusEffect as jest.Mock).mockImplementation(cb => {
cb();
});
(getStringAsync as jest.Mock).mockResolvedValue(
'https://www.someothersite.com/novel/1',
);
render(<GlobalSearchScreen />);
await waitFor(() => expect(getStringAsync).toHaveBeenCalled());
expect(screen.queryByTestId('open-novel-button')).toBeNull();
});
it('shows no button for plain text', () => {
render(<GlobalSearchScreen />);
fireEvent.changeText(screen.getByTestId('search-input'), 'abc');
expect(screen.queryByTestId('open-novel-button')).toBeNull();
});
});
@@ -0,0 +1,139 @@
import type { ReactNode } from 'react';
import { act, renderHook } from '@test-utils';
import { getMMKVObject } from '@utils/mmkv/mmkv';
import type { PluginItem } from '@plugins/types';
import { getPlugin } from '@plugins/pluginManager';
import { useGlobalSearch } from '../useGlobalSearch';
jest.mock('@hooks/persisted/useTheme', () => ({
ThemeProvider: ({ children }: { children: ReactNode }) => children,
useTheme: () => ({}),
}));
jest.mock('@components/AppErrorBoundary/AppErrorBoundary', () => ({
__esModule: true,
default: ({ children }: { children: ReactNode }) => children,
}));
jest.mock('react-native-safe-area-context', () => {
const ReactModule = require('react');
const { View } = require('react-native');
const frame = { height: 800, width: 400, x: 0, y: 0 };
const insets = { bottom: 0, left: 0, right: 0, top: 0 };
return {
SafeAreaFrameContext: ReactModule.createContext(frame),
SafeAreaInsetsContext: ReactModule.createContext(insets),
SafeAreaProvider: ({ children }: { children: ReactNode }) => children,
SafeAreaView: ({ children, ...props }: { children: ReactNode }) =>
ReactModule.createElement(View, props, children),
initialWindowMetrics: { frame, insets },
useSafeAreaFrame: () => frame,
useSafeAreaInsets: () => insets,
};
});
jest.mock('@gorhom/bottom-sheet', () => ({
BottomSheetModalProvider: ({ children }: { children: ReactNode }) => children,
}));
jest.mock('@screens/novel/NovelContext', () => ({
NovelContextProvider: ({ children }: { children: ReactNode }) => children,
}));
jest.mock('@plugins/pluginManager', () => ({
INSTALLED_PLUGINS_KEY: 'INSTALL_PLUGINS',
getPlugin: jest.fn(),
}));
jest.mock('@utils/mmkv/mmkv', () => ({
getMMKVObject: jest.fn(),
setMMKVObject: jest.fn(),
}));
jest.mock('@hooks/persisted', () => ({
useFilteredInstalledPlugins: () => [testSource],
useBrowseSettings: () => ({ globalSearchConcurrency: 1 }),
}));
const testSource: PluginItem = {
id: 'test-source',
name: 'Test Source',
site: 'https://testsource.example.com/',
lang: 'English',
version: '1.0.0',
url: 'https://example.com/test-source.js',
iconUrl: 'https://example.com/test-source.png',
};
const mockGetMMKVObject = getMMKVObject as jest.Mock;
const mockGetPlugin = getPlugin as jest.Mock;
beforeEach(() => {
jest.clearAllMocks();
mockGetMMKVObject.mockReturnValue([testSource]);
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
describe('useGlobalSearch', () => {
it('does not search for a URL matching an installed plugin', () => {
renderHook(() =>
useGlobalSearch({
defaultSearchText:
'https://testsource.example.com/fiction/21220/mother-of-learning',
}),
);
act(() => {
jest.advanceTimersByTime(400);
});
expect(mockGetPlugin).not.toHaveBeenCalled();
});
it('does not search for a URL matching no plugin', () => {
mockGetMMKVObject.mockReturnValue([]);
renderHook(() =>
useGlobalSearch({
defaultSearchText: 'https://www.someothersite.com/novel/1',
}),
);
act(() => {
jest.advanceTimersByTime(400);
});
expect(mockGetPlugin).not.toHaveBeenCalled();
});
it('searches when the search text is not a URL', async () => {
renderHook(() =>
useGlobalSearch({ defaultSearchText: 'mother of learning' }),
);
await act(async () => {
jest.advanceTimersByTime(400);
// Let the async per-plugin search chain settle inside act.
for (let i = 0; i < 20; i++) {
await Promise.resolve();
}
});
expect(mockGetPlugin).toHaveBeenCalled();
});
it('does not search when the search text is empty', () => {
renderHook(() => useGlobalSearch({ defaultSearchText: '' }));
act(() => {
jest.advanceTimersByTime(400);
});
expect(mockGetPlugin).not.toHaveBeenCalled();
});
});
@@ -8,6 +8,7 @@ import {
useFilteredInstalledPlugins,
} from '@hooks/persisted';
import { useFocusEffect } from '@react-navigation/native';
import { resolveSharedUrl } from '@services/share/resolveSharedUrl';
interface Props {
defaultSearchText?: string;
@@ -162,7 +163,12 @@ export const useGlobalSearch = ({
useEffect(() => {
if (defaultSearchText) {
debouncedGlobalSearch(defaultSearchText);
// URL text is handled by the screen's explicit submit flow
// (button / keyboard submit); never search or navigate for it.
const sharedUrlResult = resolveSharedUrl(defaultSearchText);
if (!sharedUrlResult) {
debouncedGlobalSearch(defaultSearchText);
}
}
return () => {
+33 -13
View File
@@ -19,7 +19,7 @@ import { getString } from '@i18n/translations';
import NovelAppbar from './components/NovelAppbar';
import NovelScreenList from './components/NovelScreenList';
import { ThemeColors } from '@theme/types';
import { SafeAreaView } from '@components';
import { EmptyView, SafeAreaView } from '@components';
import { useNovelActions, useNovelValue } from './NovelContext';
import { LegendListRef } from '@legendapp/list/react-native';
import { useCustomNovelCover } from './hooks/useCustomNovelCover';
@@ -32,6 +32,8 @@ import { getPageChapterIds } from '@database/queries/ChapterQueries';
const Novel = ({ route, navigation }: NovelScreenProps) => {
const novel = useNovelValue('novel');
const loading = useNovelValue('loading');
const error = useNovelValue('error');
const chapters = useNovelValue('chapters');
const novelSettings = useNovelValue('novelSettings');
const pageIndex = useNovelValue('pageIndex');
@@ -41,6 +43,8 @@ const Novel = ({ route, navigation }: NovelScreenProps) => {
const theme = useTheme();
const { downloadNewChapters, refreshNovelMetadata } = useAppSettings();
const showNovelError = !novel && !loading && error;
const getAllChapterIds = useCallback(() => {
if (!novel) {
return Promise.resolve([]);
@@ -156,6 +160,7 @@ const Novel = ({ route, navigation }: NovelScreenProps) => {
isLocal={novel?.isLocal ?? route.params?.isLocal ?? false}
goBack={navigation.goBack}
headerOpacity={headerOpacity}
hideActions={!!showNovelError}
/>
) : (
<Animated.View
@@ -181,19 +186,34 @@ const Novel = ({ route, navigation }: NovelScreenProps) => {
)}
</Portal>
<SafeAreaView excludeTop excludeBottom>
<Suspense fallback={<NovelScreenLoading theme={theme} />}>
<NovelScreenList
headerOpacity={headerOpacity}
listRef={chapterListRef}
navigation={navigation}
routeBaseNovel={route.params}
selected={selected}
setSelected={setSelected}
deleteDownloadSnackbar={deleteDownloadsSnackbar}
onRefresh={onRefresh}
updating={updating}
{showNovelError ? (
<EmptyView
icon="Σ(ಠ_ಠ)"
description={error}
theme={theme}
actions={[
{
iconName: 'arrow-left',
title: getString('common.back'),
onPress: navigation.goBack,
},
]}
/>
</Suspense>
) : (
<Suspense fallback={<NovelScreenLoading theme={theme} />}>
<NovelScreenList
headerOpacity={headerOpacity}
listRef={chapterListRef}
navigation={navigation}
routeBaseNovel={route.params}
selected={selected}
setSelected={setSelected}
deleteDownloadSnackbar={deleteDownloadsSnackbar}
onRefresh={onRefresh}
updating={updating}
/>
</Suspense>
)}
</SafeAreaView>
{novel && setCategoriesModalVisible ? (
@@ -75,7 +75,12 @@ jest.mock('../../../database/queries/NovelQueries', () => ({
jest.mock('../components/NovelAppbar', () => {
const React = require('react');
const { Text } = require('react-native');
return () => React.createElement(Text, { testID: 'novel-appbar' }, 'appbar');
return ({ hideActions }: { hideActions?: boolean }) =>
React.createElement(
Text,
{ testID: 'novel-appbar' },
hideActions ? 'appbar-actions-hidden' : 'appbar',
);
});
jest.mock('../components/NovelScreenList', () => {
@@ -146,9 +151,33 @@ jest.mock('../../../components/Actionbar/Actionbar', () => {
jest.mock('@components', () => {
const React = require('react');
const { Pressable, Text, View } = require('react-native');
return {
SafeAreaView: ({ children }: { children: React.ReactNode }) =>
React.createElement(React.Fragment, null, children),
EmptyView: ({
description,
actions,
}: {
description: string;
actions?: { iconName: string; title: string; onPress: () => void }[];
}) =>
React.createElement(
View,
null,
React.createElement(Text, null, description),
...(actions || []).map(action =>
React.createElement(
Pressable,
{
key: action.title,
testID: `emptyview-action-${action.iconName}`,
onPress: action.onPress,
},
React.createElement(Text, null, action.title),
),
),
),
};
});
@@ -242,6 +271,8 @@ const createStore = (overrides: Record<string, unknown> = {}) => {
},
],
fetching: false,
loading: false,
error: undefined,
novelSettings: {
filter: [],
excludedScanlators: [],
@@ -363,6 +394,45 @@ describe('NovelScreen (task 12 context boundary cutover)', () => {
expect(mockUpdateChapterProgressByIds).not.toHaveBeenCalled();
});
it('shows the not-found error view with back action and hidden appbar actions', () => {
const store = createStore({
novel: undefined,
loading: false,
error: 'Novel not found on the source',
});
wireStoreSelectors(store);
render(
// @ts-expect-error narrowed test props
<NovelScreen navigation={navigation} route={route} />,
);
expect(screen.getByText('Novel not found on the source')).toBeTruthy();
expect(screen.getByTestId('novel-appbar')).toHaveTextContent(
'appbar-actions-hidden',
);
fireEvent.press(screen.getByTestId('emptyview-action-arrow-left'));
expect(navigation.goBack).toHaveBeenCalled();
});
it('keeps the error view hidden while the novel is still loading', () => {
const store = createStore({
novel: undefined,
loading: true,
error: 'Novel not found on the source',
});
wireStoreSelectors(store);
render(
// @ts-expect-error narrowed test props
<NovelScreen navigation={navigation} route={route} />,
);
expect(screen.queryByText('Novel not found on the source')).toBeNull();
expect(screen.getByTestId('novel-appbar')).toHaveTextContent('appbar');
});
it('keeps undefined-novel safety path for download action and guarded modals', () => {
const store = createStore({ novel: undefined });
wireStoreSelectors(store);
+38 -34
View File
@@ -107,6 +107,7 @@ const NovelAppbar = ({
editCategories,
showJumpToChapterModal,
headerOpacity,
hideActions = false,
}: {
novel: NovelInfo | undefined;
theme: ThemeColors;
@@ -122,6 +123,7 @@ const NovelAppbar = ({
editCategories: () => void;
showJumpToChapterModal: (arg: boolean) => void;
headerOpacity: SharedValue<number>;
hideActions?: boolean;
}) => {
const headerOpacityStyle = useAnimatedStyle(() => {
const backgroundColor = interpolateColor(
@@ -249,47 +251,49 @@ const NovelAppbar = ({
<Appbar.Header theme={headerTheme}>
<Appbar.BackAction onPress={goBack} />
<View style={styles.row}>
<ExportNovelAsEpubButton
novel={novel}
renderIcon={renderExportIcon}
/>
<NovelAppbarAction
theme={theme}
icon="book-search-outline"
onPress={openJumpToChapter}
/>
{!isLocal ? (
<Menu
{hideActions ? null : (
<View style={styles.row}>
<ExportNovelAsEpubButton
novel={novel}
renderIcon={renderExportIcon}
/>
<NovelAppbarAction
theme={theme}
visible={downloadMenu}
onDismiss={closeDlMenu}
icon="book-search-outline"
onPress={openJumpToChapter}
/>
{!isLocal ? (
<Menu
theme={theme}
visible={downloadMenu}
onDismiss={closeDlMenu}
anchor={
<Appbar.Action
theme={appbarTheme}
icon="download-outline"
onPress={openDlMenu}
size={26}
/>
}
items={downloadMenuItems}
/>
) : null}
<Menu
visible={extraMenu}
onDismiss={closeExtraMenu}
anchor={
<Appbar.Action
theme={appbarTheme}
icon="download-outline"
onPress={openDlMenu}
size={26}
icon="dots-vertical"
onPress={openExtraMenu}
size={24}
/>
}
items={downloadMenuItems}
theme={theme}
items={extraMenuItems}
/>
) : null}
<Menu
visible={extraMenu}
onDismiss={closeExtraMenu}
anchor={
<Appbar.Action
theme={appbarTheme}
icon="dots-vertical"
onPress={openExtraMenu}
size={24}
/>
}
theme={theme}
items={extraMenuItems}
/>
</View>
</View>
)}
</Appbar.Header>
</Animated.View>
);
@@ -0,0 +1,132 @@
import type { PluginItem } from '@plugins/types';
import { getMMKVObject } from '@utils/mmkv/mmkv';
import { resolveSharedUrl } from '../resolveSharedUrl';
jest.mock('@plugins/pluginManager', () => ({
INSTALLED_PLUGINS_KEY: 'INSTALL_PLUGINS',
}));
jest.mock('@utils/mmkv/mmkv', () => ({
getMMKVObject: jest.fn(),
setMMKVObject: jest.fn(),
}));
const createPlugin = (id: string, site: string, name = id): PluginItem => ({
id,
name,
site,
lang: 'English',
version: '1.0.0',
url: `https://example.com/${id}.js`,
iconUrl: `https://example.com/${id}.png`,
});
const testSource = createPlugin(
'test-source',
'https://testsource.example.com/',
'Test Source',
);
const plain = createPlugin('plain', 'https://example.com');
const mockGetMMKVObject = getMMKVObject as jest.Mock;
beforeEach(() => {
mockGetMMKVObject.mockReturnValue([testSource]);
});
describe('resolveSharedUrl', () => {
it('opens the novel when one installed plugin site prefixes the URL', () => {
expect(
resolveSharedUrl(
'https://testsource.example.com/fiction/21220/mother-of-learning',
),
).toEqual({
kind: 'novel',
pluginId: 'test-source',
path: 'fiction/21220/mother-of-learning',
});
});
it('matches a site without a trailing slash and strips trailing slashes from the path', () => {
mockGetMMKVObject.mockReturnValue([plain]);
expect(resolveSharedUrl('https://example.com/novel/1/')).toEqual({
kind: 'novel',
pluginId: 'plain',
path: 'novel/1',
});
});
it('drops query and hash from the shared URL', () => {
expect(
resolveSharedUrl(
'https://testsource.example.com/fiction/21220/title?src=share#ch1',
),
).toEqual({
kind: 'novel',
pluginId: 'test-source',
path: 'fiction/21220/title',
});
});
it('falls back to search when the URL is exactly a plugin site (no path)', () => {
mockGetMMKVObject.mockReturnValue([plain]);
expect(resolveSharedUrl('https://example.com')).toEqual({
kind: 'search',
searchText: 'https://example.com',
});
});
it('falls back to search when no plugin site matches', () => {
expect(resolveSharedUrl('https://www.someothersite.com/novel/1')).toEqual({
kind: 'search',
searchText: 'https://www.someothersite.com/novel/1',
});
});
it('falls back to search when two plugins share the same site', () => {
mockGetMMKVObject.mockReturnValue([plain, { ...plain, id: 'plain2' }]);
expect(resolveSharedUrl('https://example.com/novel/1')).toEqual({
kind: 'search',
searchText: 'https://example.com/novel/1',
});
});
it('does not match a site that is only a host prefix', () => {
mockGetMMKVObject.mockReturnValue([plain]);
expect(resolveSharedUrl('https://example.com.evil.com/x')).toEqual({
kind: 'search',
searchText: 'https://example.com.evil.com/x',
});
});
it('ignores non-URL text', () => {
expect(resolveSharedUrl('hello world')).toBeUndefined();
expect(resolveSharedUrl('ftp://example.com/x')).toBeUndefined();
expect(resolveSharedUrl('')).toBeUndefined();
});
it('trims surrounding whitespace from the shared text', () => {
mockGetMMKVObject.mockReturnValue([plain]);
expect(resolveSharedUrl(' https://example.com/novel/1 ')).toEqual({
kind: 'novel',
pluginId: 'plain',
path: 'novel/1',
});
});
it('falls back to search when no plugins are installed', () => {
mockGetMMKVObject.mockReturnValue([]);
expect(
resolveSharedUrl('https://testsource.example.com/fiction/21220/x'),
).toEqual({
kind: 'search',
searchText: 'https://testsource.example.com/fiction/21220/x',
});
});
});
+65
View File
@@ -0,0 +1,65 @@
import type { PluginItem } from '@plugins/types';
import { INSTALLED_PLUGINS_KEY } from '@plugins/pluginManager';
import { getMMKVObject } from '@utils/mmkv/mmkv';
export type SharedUrlResult =
| { kind: 'novel'; pluginId: string; path: string }
| { kind: 'search'; searchText: string };
const normalizeTrailingSlash = (url: string) => url.replace(/\/+$/, '');
const normalizeSharedText = (text: string): string | undefined => {
try {
const url = new URL(text.trim());
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
return undefined;
}
// Query/fragment are dropped so the extracted path matches the paths the
// plugin's own search returns (no "?src=share" stuck to the novel path).
return url.origin + url.pathname;
} catch {
return undefined;
}
};
// Accepts only a single match so the app never picks arbitrarily between
// plugins sharing a site; caller falls back to search otherwise.
const findPluginForUrl = (
url: string,
plugins: PluginItem[],
): PluginItem | undefined => {
const normalizedUrl = normalizeTrailingSlash(url);
const matches = plugins.filter(plugin => {
const site = normalizeTrailingSlash(plugin.site);
if (!normalizedUrl.startsWith(site)) {
return false;
}
// '/' boundary guards against site "example.com" matching
// "example.com.evil.com/…".
return normalizedUrl.slice(site.length).startsWith('/');
});
return matches.length === 1 ? matches[0] : undefined;
};
export const resolveSharedUrl = (text: string): SharedUrlResult | undefined => {
const url = normalizeSharedText(text);
if (!url) {
return undefined;
}
const searchText = text.trim();
const installedPlugins =
getMMKVObject<PluginItem[]>(INSTALLED_PLUGINS_KEY) ?? [];
const plugin = findPluginForUrl(url, installedPlugins);
if (!plugin) {
return { kind: 'search', searchText };
}
// parseNovel takes a path relative to the plugin site (plugins do
// fetchApi(this.site + novelPath)); strip site prefix + slashes.
const path = url
.slice(normalizeTrailingSlash(plugin.site).length)
.replace(/^\/+|\/+$/g, '');
if (!path) {
return { kind: 'search', searchText };
}
return { kind: 'novel', pluginId: plugin.id, path };
};
+9
View File
@@ -47,6 +47,15 @@ jest.mock('@modules/native-volume-button-listener', () => ({
},
}));
jest.mock('@modules/native-share-receiver', () => ({
__esModule: true,
default: {
getInitialSharedText: jest.fn(async () => null),
addListener: jest.fn(() => ({ remove: jest.fn() })),
removeListeners: jest.fn(),
},
}));
jest.mock('@modules/native-zip-archive', () => ({
__esModule: true,
default: {
+8 -2
View File
@@ -1,5 +1,10 @@
const mockNavigate = jest.fn();
const mockSetOptions = jest.fn();
const mockNavigationRef = {
isReady: jest.fn(() => true),
navigate: mockNavigate,
getCurrentRoute: jest.fn(() => null),
};
// Patch react-native-worklets mock so reanimated setup doesn't crash.
// Reanimated v4.5.2 imports createShareable, UIRuntimeId, getUIRuntimeHolder,
@@ -14,7 +19,7 @@ jest.mock('react-native-worklets', () => {
getUISchedulerHolder: () => ({}),
createShareable: (_hostRuntimeId, initial) => initial,
UIRuntimeId: 2,
toggleSlowAnimationsOnUIRuntime: (_enabled) => { },
toggleSlowAnimationsOnUIRuntime: _enabled => {},
};
});
@@ -39,7 +44,8 @@ jest.mock('@react-navigation/native', () => {
useRoute: () => ({
params: {},
}),
createNavigationContainerRef: () => mockNavigationRef,
};
});
module.exports = { mockNavigate, mockSetOptions };
module.exports = { mockNavigate, mockSetOptions, mockNavigationRef };