From 77be5624f2ac45ff9be9b957682c623754d5c3dd Mon Sep 17 00:00:00 2001 From: Patrick Loser Date: Tue, 1 Sep 2026 15:29:32 +0200 Subject: [PATCH] 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 --- app.json | 7 + .../android/build.gradle | 18 ++ .../NativeShareReceiverModule.kt | 37 +++ .../expo-module.config.json | 6 + modules/native-share-receiver/index.ts | 2 + modules/native-share-receiver/package.json | 3 + .../src/NativeShareReceiverModule.ts | 13 + .../__tests__/novelStore.bootstrap.test.ts | 155 +++++++++ .../useNovel/store/novelStore.actions.ts | 16 +- .../persisted/useNovel/store/novelStore.ts | 1 + .../useNovel/store/novelStore.types.ts | 1 + src/i18n/languages/en/strings.json | 9 +- src/i18n/types/index.ts | 5 + src/navigators/Main.tsx | 10 +- src/navigators/ShareIntentHandler.tsx | 84 +++++ .../__tests__/ShareIntentHandler.test.tsx | 224 +++++++++++++ .../GlobalSearchScreen/GlobalSearchScreen.tsx | 119 ++++++- .../__tests__/GlobalSearchScreen.test.tsx | 304 ++++++++++++++++++ .../hooks/__tests__/useGlobalSearch.test.tsx | 139 ++++++++ .../hooks/useGlobalSearch.ts | 8 +- src/screens/novel/NovelScreen.tsx | 46 ++- .../novel/__tests__/NovelScreen.test.tsx | 72 ++++- src/screens/novel/components/NovelAppbar.tsx | 72 +++-- .../share/__tests__/resolveSharedUrl.test.ts | 132 ++++++++ src/services/share/resolveSharedUrl.ts | 65 ++++ test/mocks/nativeModules.js | 9 + test/mocks/react-navigation.js | 10 +- 27 files changed, 1508 insertions(+), 59 deletions(-) create mode 100644 modules/native-share-receiver/android/build.gradle create mode 100644 modules/native-share-receiver/android/src/main/java/expo/modules/nativesharereceiver/NativeShareReceiverModule.kt create mode 100644 modules/native-share-receiver/expo-module.config.json create mode 100644 modules/native-share-receiver/index.ts create mode 100644 modules/native-share-receiver/package.json create mode 100644 modules/native-share-receiver/src/NativeShareReceiverModule.ts create mode 100644 src/hooks/persisted/useNovel/__tests__/novelStore.bootstrap.test.ts create mode 100644 src/navigators/ShareIntentHandler.tsx create mode 100644 src/navigators/__tests__/ShareIntentHandler.test.tsx create mode 100644 src/screens/GlobalSearchScreen/__tests__/GlobalSearchScreen.test.tsx create mode 100644 src/screens/GlobalSearchScreen/hooks/__tests__/useGlobalSearch.test.tsx create mode 100644 src/services/share/__tests__/resolveSharedUrl.test.ts create mode 100644 src/services/share/resolveSharedUrl.ts diff --git a/app.json b/app.json index be32437c..ec9c7602 100644 --- a/app.json +++ b/app.json @@ -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": { diff --git a/modules/native-share-receiver/android/build.gradle b/modules/native-share-receiver/android/build.gradle new file mode 100644 index 00000000..33f269fe --- /dev/null +++ b/modules/native-share-receiver/android/build.gradle @@ -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 + } +} diff --git a/modules/native-share-receiver/android/src/main/java/expo/modules/nativesharereceiver/NativeShareReceiverModule.kt b/modules/native-share-receiver/android/src/main/java/expo/modules/nativesharereceiver/NativeShareReceiverModule.kt new file mode 100644 index 00000000..b3d3ff5c --- /dev/null +++ b/modules/native-share-receiver/android/src/main/java/expo/modules/nativesharereceiver/NativeShareReceiverModule.kt @@ -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) + } +} diff --git a/modules/native-share-receiver/expo-module.config.json b/modules/native-share-receiver/expo-module.config.json new file mode 100644 index 00000000..ccde9f59 --- /dev/null +++ b/modules/native-share-receiver/expo-module.config.json @@ -0,0 +1,6 @@ +{ + "platforms": ["android"], + "android": { + "modules": ["expo.modules.nativesharereceiver.NativeShareReceiverModule"] + } +} diff --git a/modules/native-share-receiver/index.ts b/modules/native-share-receiver/index.ts new file mode 100644 index 00000000..4644edd6 --- /dev/null +++ b/modules/native-share-receiver/index.ts @@ -0,0 +1,2 @@ +import NativeShareReceiver from './src/NativeShareReceiverModule'; +export default NativeShareReceiver; diff --git a/modules/native-share-receiver/package.json b/modules/native-share-receiver/package.json new file mode 100644 index 00000000..e10a72d9 --- /dev/null +++ b/modules/native-share-receiver/package.json @@ -0,0 +1,3 @@ +{ + "name": "native-share-receiver" +} diff --git a/modules/native-share-receiver/src/NativeShareReceiverModule.ts b/modules/native-share-receiver/src/NativeShareReceiverModule.ts new file mode 100644 index 00000000..297e1620 --- /dev/null +++ b/modules/native-share-receiver/src/NativeShareReceiverModule.ts @@ -0,0 +1,13 @@ +import { NativeModule, requireNativeModule } from 'expo-modules-core'; + +export type NativeShareReceiverEvents = { + SharedText: (payload: { text: string }) => void; +}; + +declare class NativeShareReceiverModule extends NativeModule { + getInitialSharedText(): string | null; +} + +export default requireNativeModule( + 'NativeShareReceiver', +); diff --git a/src/hooks/persisted/useNovel/__tests__/novelStore.bootstrap.test.ts b/src/hooks/persisted/useNovel/__tests__/novelStore.bootstrap.test.ts new file mode 100644 index 00000000..2863aa36 --- /dev/null +++ b/src/hooks/persisted/useNovel/__tests__/novelStore.bootstrap.test.ts @@ -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 = {}) => { + 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 | ((s: TestState) => Partial)) => { + const patch = typeof partial === 'function' ? partial(state) : partial; + state = { ...state, ...patch }; + }, + ); + const get = () => state; + const bootstrapService: jest.Mocked = { + 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); + }); +}); diff --git a/src/hooks/persisted/useNovel/store/novelStore.actions.ts b/src/hooks/persisted/useNovel/store/novelStore.actions.ts index d39d691b..736587fa 100644 --- a/src/hooks/persisted/useNovel/store/novelStore.actions.ts +++ b/src/hooks/persisted/useNovel/store/novelStore.actions.ts @@ -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; } diff --git a/src/hooks/persisted/useNovel/store/novelStore.ts b/src/hooks/persisted/useNovel/store/novelStore.ts index 7e866634..25185c68 100644 --- a/src/hooks/persisted/useNovel/store/novelStore.ts +++ b/src/hooks/persisted/useNovel/store/novelStore.ts @@ -23,6 +23,7 @@ export const createNovelSlice = ({ return { loading: false, fetching: false, + error: undefined, pluginId, novelPath, novel, diff --git a/src/hooks/persisted/useNovel/store/novelStore.types.ts b/src/hooks/persisted/useNovel/store/novelStore.types.ts index b7637fdb..27f75468 100644 --- a/src/hooks/persisted/useNovel/store/novelStore.types.ts +++ b/src/hooks/persisted/useNovel/store/novelStore.types.ts @@ -23,6 +23,7 @@ export interface ChapterSliceState { export interface NovelStoreData extends ChapterSliceState { loading: boolean; fetching: boolean; + error: string | undefined; pluginId: string; novelPath: string; diff --git a/src/i18n/languages/en/strings.json b/src/i18n/languages/en/strings.json index 678231b3..e289b312 100644 --- a/src/i18n/languages/en/strings.json +++ b/src/i18n/languages/en/strings.json @@ -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", diff --git a/src/i18n/types/index.ts b/src/i18n/types/index.ts index 40bbd001..17f59db6 100644 --- a/src/i18n/types/index.ts +++ b/src/i18n/types/index.ts @@ -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'; diff --git a/src/navigators/Main.tsx b/src/navigators/Main.tsx index ca1165b0..fa726749 100644 --- a/src/navigators/Main.tsx +++ b/src/navigators/Main.tsx @@ -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 ( ref={navigationRef} + onReady={flushPendingShare} theme={{ colors: { ...DefaultTheme.colors, @@ -108,6 +111,7 @@ const MainNavigator = () => { }, }} > + diff --git a/src/navigators/ShareIntentHandler.tsx b/src/navigators/ShareIntentHandler.tsx new file mode 100644 index 00000000..953eb558 --- /dev/null +++ b/src/navigators/ShareIntentHandler.tsx @@ -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(); + +// 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; diff --git a/src/navigators/__tests__/ShareIntentHandler.test.tsx b/src/navigators/__tests__/ShareIntentHandler.test.tsx new file mode 100644 index 00000000..883bc489 --- /dev/null +++ b/src/navigators/__tests__/ShareIntentHandler.test.tsx @@ -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(); + + 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(); + + 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(); + // 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(); + + 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(); + // 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(); + + 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(); + + const subscription = (NativeShareReceiver.addListener as jest.Mock).mock + .results[0].value; + expect(subscription.remove).toBeDefined(); + + unmount(); + + expect(subscription.remove).toHaveBeenCalled(); + }); +}); diff --git a/src/screens/GlobalSearchScreen/GlobalSearchScreen.tsx b/src/screens/GlobalSearchScreen/GlobalSearchScreen.tsx index eb0524ad..8c7c64d3 100644 --- a/src/screens/GlobalSearchScreen/GlobalSearchScreen.tsx +++ b/src/screens/GlobalSearchScreen/GlobalSearchScreen.tsx @@ -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 ( { placeholder={getString('browseScreen.globalSearch')} leftIcon="magnify" onChangeText={onChangeText} + onSubmitEditing={handleSubmit} clearSearchbar={clearSearchbar} theme={theme} /> @@ -80,6 +172,23 @@ const GlobalSearchScreen = (props: Props) => { /> } /> + {openNovelOffer ? ( + + ) : null} ); }; @@ -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, diff --git a/src/screens/GlobalSearchScreen/__tests__/GlobalSearchScreen.test.tsx b/src/screens/GlobalSearchScreen/__tests__/GlobalSearchScreen.test.tsx new file mode 100644 index 00000000..0838e6f8 --- /dev/null +++ b/src/screens/GlobalSearchScreen/__tests__/GlobalSearchScreen.test.tsx @@ -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(); + + 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(); + + 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(); + + 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(); + + 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(); + + await waitFor(() => expect(getStringAsync).toHaveBeenCalled()); + + expect(screen.queryByTestId('open-novel-button')).toBeNull(); + }); + + it('shows no button for plain text', () => { + render(); + + fireEvent.changeText(screen.getByTestId('search-input'), 'abc'); + + expect(screen.queryByTestId('open-novel-button')).toBeNull(); + }); +}); diff --git a/src/screens/GlobalSearchScreen/hooks/__tests__/useGlobalSearch.test.tsx b/src/screens/GlobalSearchScreen/hooks/__tests__/useGlobalSearch.test.tsx new file mode 100644 index 00000000..8f57e419 --- /dev/null +++ b/src/screens/GlobalSearchScreen/hooks/__tests__/useGlobalSearch.test.tsx @@ -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(); + }); +}); diff --git a/src/screens/GlobalSearchScreen/hooks/useGlobalSearch.ts b/src/screens/GlobalSearchScreen/hooks/useGlobalSearch.ts index 54118d4e..5442b0e9 100644 --- a/src/screens/GlobalSearchScreen/hooks/useGlobalSearch.ts +++ b/src/screens/GlobalSearchScreen/hooks/useGlobalSearch.ts @@ -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 () => { diff --git a/src/screens/novel/NovelScreen.tsx b/src/screens/novel/NovelScreen.tsx index de7b1001..67106959 100644 --- a/src/screens/novel/NovelScreen.tsx +++ b/src/screens/novel/NovelScreen.tsx @@ -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} /> ) : ( { )} - }> - - + ) : ( + }> + + + )} {novel && setCategoriesModalVisible ? ( diff --git a/src/screens/novel/__tests__/NovelScreen.test.tsx b/src/screens/novel/__tests__/NovelScreen.test.tsx index 65e78a1c..69b3575c 100644 --- a/src/screens/novel/__tests__/NovelScreen.test.tsx +++ b/src/screens/novel/__tests__/NovelScreen.test.tsx @@ -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 = {}) => { }, ], 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 + , + ); + + 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 + , + ); + + 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); diff --git a/src/screens/novel/components/NovelAppbar.tsx b/src/screens/novel/components/NovelAppbar.tsx index 5117cc33..37fe545a 100644 --- a/src/screens/novel/components/NovelAppbar.tsx +++ b/src/screens/novel/components/NovelAppbar.tsx @@ -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; + hideActions?: boolean; }) => { const headerOpacityStyle = useAnimatedStyle(() => { const backgroundColor = interpolateColor( @@ -249,47 +251,49 @@ const NovelAppbar = ({ - - - - {!isLocal ? ( - + + + {!isLocal ? ( + + } + items={downloadMenuItems} + /> + ) : null} + } - items={downloadMenuItems} + theme={theme} + items={extraMenuItems} /> - ) : null} - - } - theme={theme} - items={extraMenuItems} - /> - + + )} ); diff --git a/src/services/share/__tests__/resolveSharedUrl.test.ts b/src/services/share/__tests__/resolveSharedUrl.test.ts new file mode 100644 index 00000000..bc0e9b55 --- /dev/null +++ b/src/services/share/__tests__/resolveSharedUrl.test.ts @@ -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', + }); + }); +}); diff --git a/src/services/share/resolveSharedUrl.ts b/src/services/share/resolveSharedUrl.ts new file mode 100644 index 00000000..4c4fa6be --- /dev/null +++ b/src/services/share/resolveSharedUrl.ts @@ -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(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 }; +}; diff --git a/test/mocks/nativeModules.js b/test/mocks/nativeModules.js index 9ba7dbeb..6f2256e1 100644 --- a/test/mocks/nativeModules.js +++ b/test/mocks/nativeModules.js @@ -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: { diff --git a/test/mocks/react-navigation.js b/test/mocks/react-navigation.js index 8a1c8717..d72ea79f 100644 --- a/test/mocks/react-navigation.js +++ b/test/mocks/react-navigation.js @@ -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 };