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:
@@ -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
|
||||
}
|
||||
}
|
||||
+37
@@ -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"]
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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: {
|
||||
|
||||
Vendored
+8
-2
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user