diff --git a/apps/readest-app/next.config.mjs b/apps/readest-app/next.config.mjs index eecbbefa..df118f45 100644 --- a/apps/readest-app/next.config.mjs +++ b/apps/readest-app/next.config.mjs @@ -31,6 +31,7 @@ const nextConfig = { assetPrefix: '', reactStrictMode: true, serverExternalPackages: ['isows'], + allowedDevOrigins: ['192.168.2.120'], webpack: (config) => { config.resolve.alias = { ...config.resolve.alias, diff --git a/apps/readest-app/src-tauri/Info-ios.plist b/apps/readest-app/src-tauri/Info-ios.plist index d679effa..52283c2e 100644 --- a/apps/readest-app/src-tauri/Info-ios.plist +++ b/apps/readest-app/src-tauri/Info-ios.plist @@ -4,5 +4,16 @@ LSSupportsOpeningDocumentsInPlace + CFBundleURLTypes + + + CFBundleURLName + com.bilingify.readest + CFBundleURLSchemes + + readest + + + diff --git a/apps/readest-app/src-tauri/gen/android/app/src/main/AndroidManifest.xml b/apps/readest-app/src-tauri/gen/android/app/src/main/AndroidManifest.xml index d6334dfd..17643be2 100644 --- a/apps/readest-app/src-tauri/gen/android/app/src/main/AndroidManifest.xml +++ b/apps/readest-app/src-tauri/gen/android/app/src/main/AndroidManifest.xml @@ -107,12 +107,22 @@ - + + + + + + + + + + + diff --git a/apps/readest-app/src/__tests__/store/reader-store.test.ts b/apps/readest-app/src/__tests__/store/reader-store.test.ts index 67b2bcc5..c5aad1bd 100644 --- a/apps/readest-app/src/__tests__/store/reader-store.test.ts +++ b/apps/readest-app/src/__tests__/store/reader-store.test.ts @@ -84,6 +84,7 @@ function seedViewState(key: string, overrides: Record = {}) { ttsEnabled: false, syncing: false, gridInsets: null, + previewMode: false, viewSettings: null, ...overrides, }, diff --git a/apps/readest-app/src/app/library/page.tsx b/apps/readest-app/src/app/library/page.tsx index 758182b4..08f66ca8 100644 --- a/apps/readest-app/src/app/library/page.tsx +++ b/apps/readest-app/src/app/library/page.tsx @@ -39,6 +39,7 @@ import { useOPDSSubscriptions } from '@/hooks/useOPDSSubscriptions'; import { useBookDataStore } from '@/store/bookDataStore'; import { useTransferStore } from '@/store/transferStore'; import { useScreenWakeLock } from '@/hooks/useScreenWakeLock'; +import { useAppUrlIngress } from '@/hooks/useAppUrlIngress'; import { useOpenWithBooks } from '@/hooks/useOpenWithBooks'; import { useOpenAnnotationLink } from '@/hooks/useOpenAnnotationLink'; import { useKeyDownActions } from '@/hooks/useKeyDownActions'; @@ -160,6 +161,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP useTheme({ systemUIVisible: true, appThemeColor: 'base-200' }); useUICSS(); + useAppUrlIngress(); useOpenWithBooks(); useOpenAnnotationLink(); useTransferQueue(libraryLoaded); diff --git a/apps/readest-app/src/app/o/page.tsx b/apps/readest-app/src/app/o/page.tsx index 4d0ded4d..8a8ded12 100644 --- a/apps/readest-app/src/app/o/page.tsx +++ b/apps/readest-app/src/app/o/page.tsx @@ -146,7 +146,7 @@ const OpenAnnotationLanding = () => { // Invalid link — missing book or note identifier. if (!bookHash || !noteId) { return ( -
+
@@ -174,7 +174,7 @@ const OpenAnnotationLanding = () => { const webReaderHref = buildWebReaderUrl(bookHash, cfi); return ( -
+
s.setPreviewMode); const { getViewState, getProgress, getViewSettings, setViewSettings } = useReaderStore(); const { getParallels } = useParallelViewStore(); const { getBookData } = useBookDataStore(); @@ -355,6 +356,11 @@ const FoliateViewer: React.FC<{ const detail = (event as CustomEvent).detail; if (detail.reason !== 'scroll' && detail.reason !== 'page') return; + // First user-initiated navigation after a deep-link landing — promote + // the preview into the real reading position. Subsequent progress writes + // can flow normally. + setPreviewMode(bookKey, false); + const parallelViews = getParallels(bookKey); if (parallelViews && parallelViews.size > 0) { parallelViews.forEach((key) => { @@ -602,6 +608,15 @@ const FoliateViewer: React.FC<{ await view.goToFraction(0); } setViewInited(bookKey, true); + + // The reader is showing a deep-link target, not the user's actual reading + // position. Mark the view as a preview so progress writers (auto-save, + // cloud sync, kosync) skip until the user takes a reading action. The + // flag clears on the first user-initiated relocate (page / scroll) in + // docRelocateHandler below. + if (overrideLocation) { + setPreviewMode(bookKey, true); + } }; openBook(); diff --git a/apps/readest-app/src/app/reader/hooks/useKOSync.ts b/apps/readest-app/src/app/reader/hooks/useKOSync.ts index 3fd62264..84233c78 100644 --- a/apps/readest-app/src/app/reader/hooks/useKOSync.ts +++ b/apps/readest-app/src/app/reader/hooks/useKOSync.ts @@ -308,12 +308,15 @@ export const useKOSync = (bookKey: string) => { // Push: auto-push progress when progress changes with a debounce useEffect(() => { if (syncState === 'synced' && progress) { + // Skip auto-pushes while previewing a deep-link target. Manual pushes + // via the 'push-kosync' event are still respected (explicit user intent). + if (useReaderStore.getState().getViewState(bookKey)?.previewMode) return; const { strategy, enabled } = settings.kosync; if (strategy !== 'receive' && enabled) { syncRefs.current.pushProgress(); } } - }, [progress, syncState, settings.kosync]); + }, [progress, syncState, settings.kosync, bookKey]); useWindowActiveChanged((isActive) => { const { pushProgress, pullProgress } = syncRefs.current; diff --git a/apps/readest-app/src/app/reader/hooks/useProgressAutoSave.ts b/apps/readest-app/src/app/reader/hooks/useProgressAutoSave.ts index 86b90b7f..a81d13ea 100644 --- a/apps/readest-app/src/app/reader/hooks/useProgressAutoSave.ts +++ b/apps/readest-app/src/app/reader/hooks/useProgressAutoSave.ts @@ -15,6 +15,9 @@ export const useProgressAutoSave = (bookKey: string) => { const saveBookConfig = useCallback( debounce(() => { setTimeout(async () => { + // Skip while previewing a deep-link target — the user's actual + // last-read position should not be overwritten by a transient view. + if (useReaderStore.getState().getViewState(bookKey)?.previewMode) return; const config = getConfig(bookKey); if (!config) return; const settings = useSettingsStore.getState().settings; diff --git a/apps/readest-app/src/app/reader/hooks/useProgressSync.ts b/apps/readest-app/src/app/reader/hooks/useProgressSync.ts index bb41912b..69ebe394 100644 --- a/apps/readest-app/src/app/reader/hooks/useProgressSync.ts +++ b/apps/readest-app/src/app/reader/hooks/useProgressSync.ts @@ -50,6 +50,9 @@ export const useProgressSync = (bookKey: string) => { if (!configPulled.current) { pullConfig(bookKey); } else { + // Skip pushes while previewing a deep-link target — the position in + // memory reflects the annotation, not what the user is actually reading. + if (useReaderStore.getState().getViewState(bookKey)?.previewMode) return; const config = getConfig(bookKey); const view = getView(bookKey); const book = getBookData(bookKey)?.book; @@ -151,7 +154,12 @@ export const useProgressSync = (bookKey: string) => { } if (remoteCFILocation && configCFI) { if (CFI.compare(configCFI, remoteCFILocation) < 0) { - if (view) { + // While previewing a deep-link target, do NOT yank the view to the + // remote position — the user came here to look at a specific + // annotation. The local config still gets updated above; the next + // open will resolve to the synced position normally. + const isPreview = useReaderStore.getState().getViewState(bookKey)?.previewMode; + if (view && !isPreview) { view.goTo(remoteCFILocation); setHoveredBookKey(null); eventDispatcher.dispatch('hint', { diff --git a/apps/readest-app/src/app/reader/page.tsx b/apps/readest-app/src/app/reader/page.tsx index dd7b56dc..014bdb8f 100644 --- a/apps/readest-app/src/app/reader/page.tsx +++ b/apps/readest-app/src/app/reader/page.tsx @@ -3,6 +3,7 @@ import { useEffect } from 'react'; import { useEnv } from '@/context/EnvContext'; import { useTranslation } from '@/hooks/useTranslation'; +import { useAppUrlIngress } from '@/hooks/useAppUrlIngress'; import { useOpenWithBooks } from '@/hooks/useOpenWithBooks'; import { useOpenAnnotationLink } from '@/hooks/useOpenAnnotationLink'; import { useSettingsStore } from '@/store/settingsStore'; @@ -16,6 +17,7 @@ export default function Page() { const { appService } = useEnv(); const { settings } = useSettingsStore(); + useAppUrlIngress(); useOpenWithBooks(); useOpenAnnotationLink(); diff --git a/apps/readest-app/src/hooks/useAppUrlIngress.ts b/apps/readest-app/src/hooks/useAppUrlIngress.ts new file mode 100644 index 00000000..cc8c0b33 --- /dev/null +++ b/apps/readest-app/src/hooks/useAppUrlIngress.ts @@ -0,0 +1,103 @@ +import { useEffect, useRef } from 'react'; +import { addPluginListener, PluginListener } from '@tauri-apps/api/core'; +import { onOpenUrl } from '@tauri-apps/plugin-deep-link'; +import { getCurrentWindow } from '@tauri-apps/api/window'; +import { useEnv } from '@/context/EnvContext'; +import { isTauriAppPlatform } from '@/services/environment'; +import { eventDispatcher } from '@/utils/event'; + +interface SingleInstancePayload { + args: string[]; + cwd: string; +} + +interface OpenFilesPayload { + files: string[]; +} + +interface SharedIntentPayload { + urls: string[]; +} + +/** + * Single ingress point for incoming URLs from the operating system. + * + * Subscribes to every Tauri channel that can deliver a URL on any platform: + * - `single-instance` event — Win/Linux deep link, macOS open-file + * - `open-files` event — macOS in-app open-files + * - `shared-intent` plugin — Android "Share to Readest" intent + * - `onOpenUrl` — iOS / Android / macOS via Tauri v2 + * + * Re-broadcasts every URL list as the `app-incoming-url` event. Consumers + * subscribe to the event instead of the underlying channels, which: + * - decouples them from platform specifics + * - sidesteps a Tauri Android quirk where multiple `onOpenUrl` + * subscribers don't all fire + * - keeps the channel-subscription code in exactly one place + * + * Existing consumers: + * - `useOpenWithBooks` — file imports + * - `useOpenAnnotationLink` — annotation deep links + * + * Cold-start URLs (`getCurrent()`) are intentionally NOT read here. Cold- + * start handling is consumer-specific (a launching file goes through the + * library init flow; an annotation jumps the reader), so each consumer + * reads `getCurrent()` itself when it needs to. + */ +export function useAppUrlIngress() { + const { appService } = useEnv(); + const listened = useRef(false); + + useEffect(() => { + if (!isTauriAppPlatform() || !appService) return; + if (listened.current) return; + listened.current = true; + + const dispatch = (urls: string[]) => { + if (!urls.length) return; + console.log('App incoming URL:', urls); + eventDispatcher.dispatch('app-incoming-url', { urls }); + }; + + const unlistenSingleInstance = getCurrentWindow().listen( + 'single-instance', + ({ payload }) => { + const url = payload.args?.[1]; + if (url) dispatch([url]); + }, + ); + + const unlistenOpenFiles = getCurrentWindow().listen( + 'open-files', + ({ payload }) => { + if (payload.files?.length) dispatch(payload.files); + }, + ); + + // FIXME: register/unregister of this plugin listener has caused freezes + // on iOS in the past, so it's gated to Android. The Tauri v2 onOpenUrl + // listener below covers iOS. + let unlistenSharedIntent: Promise | null = null; + if (appService?.isAndroidApp) { + unlistenSharedIntent = addPluginListener( + 'native-bridge', + 'shared-intent', + (payload) => { + if (payload.urls?.length) dispatch(payload.urls); + }, + ); + } + + const unlistenOpenUrl = onOpenUrl((urls) => { + if (urls?.length) dispatch(urls); + }); + + return () => { + unlistenSingleInstance.then((f) => f()); + unlistenOpenFiles.then((f) => f()); + unlistenOpenUrl.then((f) => f()); + unlistenSharedIntent?.then((f) => f.unregister()); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [appService]); +} diff --git a/apps/readest-app/src/hooks/useOpenAnnotationLink.ts b/apps/readest-app/src/hooks/useOpenAnnotationLink.ts index 30aabc67..0fe511df 100644 --- a/apps/readest-app/src/hooks/useOpenAnnotationLink.ts +++ b/apps/readest-app/src/hooks/useOpenAnnotationLink.ts @@ -1,52 +1,56 @@ -import { useEffect, useRef } from 'react'; +import { useCallback, useEffect, useRef } from 'react'; import { useRouter } from 'next/navigation'; -import { getCurrent, onOpenUrl } from '@tauri-apps/plugin-deep-link'; -import { getCurrentWindow } from '@tauri-apps/api/window'; +import { getCurrent } from '@tauri-apps/plugin-deep-link'; import { useEnv } from '@/context/EnvContext'; import { useLibraryStore } from '@/store/libraryStore'; +import { useReaderStore } from '@/store/readerStore'; import { isTauriAppPlatform } from '@/services/environment'; import { navigateToReader } from '@/utils/nav'; import { eventDispatcher } from '@/utils/event'; import { parseAnnotationDeepLink, AnnotationDeepLink } from '@/utils/deeplink'; import { useTranslation } from './useTranslation'; -interface SingleInstancePayload { - args: string[]; - cwd: string; -} +// Module-scoped — survives hook remounts (library → reader → library on +// book close). Tauri's getCurrent() keeps returning the launch URL for the +// lifetime of the app session, so without this flag every remount would +// re-process the cold-start URL and navigate back to the deep-link target +// in a loop. +let coldStartConsumed = false; /** - * Listen for incoming deep links that target an annotation, and navigate the - * reader to the corresponding book + CFI. Handles both: + * Receive annotation deep links and navigate the reader accordingly. + * + * Architecture: + * - useOpenWithBooks owns the Tauri URL channels (onOpenUrl, + * single-instance, shared-intent, open-files) and re-broadcasts every + * URL as the 'app-incoming-url' event. This hook subscribes to that + * event for the warm-start / live path. + * - For cold-start (app launched FROM the URL), getCurrent() is read + * once at module scope. useOpenWithBooks doesn't do this — its + * channels only fire for live deliveries. + * - Library-load deferral: on cold-start the URL may arrive before the + * library store has hydrated. Stash and replay once libraryLoaded. + * + * Supported URL shapes (see src/utils/deeplink.ts): * readest://book/{hash}/annotation/{id}?cfi=... * https://web.readest.com/o/book/{hash}/annotation/{id}?cfi=... - * and the legacy flat shape readest://annotation/{hash}/{id} (Readwise sync - * before the migration). + * readest://annotation/{hash}/{id} (legacy Readwise sync) * - * Cold-start vs. live: if the app is launched FROM the URL (cold-start), the - * URL lives in getCurrent() and never fires on onOpenUrl. On warm/running - * apps, it arrives via onOpenUrl (mobile) or the single-instance event - * (Windows/Linux/macOS). We cover all three. - * - * Library-load deferral: book lookup needs `libraryLoaded` to be true. On - * cold-start the hook may mount before the library hydrates, so we stash the - * pending parsed link in a ref and process it once the store reports loaded. + * Already-open shortcut: if the target book has a mounted view, jump in + * place via view.goTo(cfi). router.push to the same /reader path with a + * different cfi query does NOT re-run the reader's init effect, so + * navigation alone wouldn't move the view in that case. */ export function useOpenAnnotationLink() { const _ = useTranslation(); - const { appService } = useEnv(); const router = useRouter(); + const { appService } = useEnv(); const getBookByHash = useLibraryStore((s) => s.getBookByHash); const libraryLoaded = useLibraryStore((s) => s.libraryLoaded); - - const listened = useRef(false); const pending = useRef(null); - const handledColdStart = useRef(false); - useEffect(() => { - if (!isTauriAppPlatform() || !appService) return; - - const resolveAndNavigate = (parsed: AnnotationDeepLink) => { + const resolveAndNavigate = useCallback( + (parsed: AnnotationDeepLink) => { const { bookHash, cfi } = parsed; const book = getBookByHash(bookHash); if (!book) { @@ -57,80 +61,64 @@ export function useOpenAnnotationLink() { }); return; } + + const { viewStates, setPreviewMode } = useReaderStore.getState(); + const openEntry = Object.entries(viewStates).find( + ([key, state]) => key.startsWith(bookHash) && state.view, + ); + if (openEntry) { + const [bookKey, state] = openEntry; + if (cfi) { + state.view!.goTo(cfi); + setPreviewMode(bookKey, true); + } + return; + } + const queryParams = cfi ? `cfi=${encodeURIComponent(cfi)}` : undefined; navigateToReader(router, [bookHash], queryParams); - }; + }, + [_, getBookByHash, router], + ); + + useEffect(() => { + if (!isTauriAppPlatform() || !appService) return; const handle = (url: string) => { const parsed = parseAnnotationDeepLink(url); - if (!parsed) return false; - // If the library hasn't finished loading yet, stash the link and let - // the libraryLoaded effect below pick it up. Otherwise navigate now. + if (!parsed) return; if (!useLibraryStore.getState().libraryLoaded) { pending.current = parsed; - } else { - resolveAndNavigate(parsed); + return; } - return true; + resolveAndNavigate(parsed); }; - if (listened.current) return; - listened.current = true; - - if (!handledColdStart.current) { - handledColdStart.current = true; + if (!coldStartConsumed) { + coldStartConsumed = true; getCurrent() - .then((urls) => { - if (!urls) return; - for (const url of urls) { - if (handle(url)) break; - } - }) + .then((urls) => urls?.forEach(handle)) .catch(() => { - // getCurrent() may reject on platforms without the plugin; the - // listeners below cover live events. + // Plugin not available on this platform — live channel still works. }); } - const unlistenSingleInstance = getCurrentWindow().listen( - 'single-instance', - ({ payload }) => { - const url = payload.args?.[1]; - if (url) handle(url); - }, - ); - - const unlistenOpenUrl = onOpenUrl((urls) => { - for (const url of urls) { - if (handle(url)) break; - } - }); + const onIncoming = (event: CustomEvent) => { + const { urls } = event.detail as { urls: string[] }; + urls.forEach(handle); + }; + eventDispatcher.on('app-incoming-url', onIncoming); return () => { - unlistenSingleInstance.then((f) => f()); - unlistenOpenUrl.then((f) => f()); + eventDispatcher.off('app-incoming-url', onIncoming); }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [appService]); + }, [appService, resolveAndNavigate]); + // Replay any deferred deep link once the library hydrates. useEffect(() => { - if (!libraryLoaded) return; + if (!libraryLoaded || !pending.current) return; const parsed = pending.current; - if (!parsed) return; pending.current = null; - - const { bookHash, cfi } = parsed; - const book = getBookByHash(bookHash); - if (!book) { - eventDispatcher.dispatch('toast', { - type: 'warning', - message: _('Book not in your library'), - timeout: 2500, - }); - return; - } - const queryParams = cfi ? `cfi=${encodeURIComponent(cfi)}` : undefined; - navigateToReader(router, [bookHash], queryParams); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [libraryLoaded]); + resolveAndNavigate(parsed); + }, [libraryLoaded, resolveAndNavigate]); } diff --git a/apps/readest-app/src/hooks/useOpenWithBooks.ts b/apps/readest-app/src/hooks/useOpenWithBooks.ts index f7aa9c41..2e774738 100644 --- a/apps/readest-app/src/hooks/useOpenWithBooks.ts +++ b/apps/readest-app/src/hooks/useOpenWithBooks.ts @@ -1,56 +1,51 @@ -import { useEffect, useRef } from 'react'; +import { useEffect } from 'react'; import { useRouter } from 'next/navigation'; +import { getAllWindows, getCurrentWindow } from '@tauri-apps/api/window'; import { useEnv } from '@/context/EnvContext'; import { useLibraryStore } from '@/store/libraryStore'; import { useSettingsStore } from '@/store/settingsStore'; -import { addPluginListener, PluginListener } from '@tauri-apps/api/core'; -import { onOpenUrl } from '@tauri-apps/plugin-deep-link'; -import { getCurrentWindow, getAllWindows } from '@tauri-apps/api/window'; import { isTauriAppPlatform } from '@/services/environment'; import { navigateToLibrary, showLibraryWindow } from '@/utils/nav'; +import { eventDispatcher } from '@/utils/event'; -interface SingleInstancePayload { - args: string[]; - cwd: string; -} - -interface OpenFilesPayload { - files: string[]; -} - -interface SharedIntentPayload { - urls: string[]; -} - +/** + * Handle "Open with Readest" file imports. Consumes the `app-incoming-url` + * event published by `useAppUrlIngress`, filters URLs that look like a file + * (file://, content://, or plain path), and routes them to the library. + * + * Non-file URL shapes (https, readest://, data:, blob:) are skipped here + * — other consumers (e.g. `useOpenAnnotationLink`) act on those. + * + * Mount this hook alongside `useAppUrlIngress` so the ingress dispatcher is + * actually running when URLs arrive. + */ export function useOpenWithBooks() { const router = useRouter(); const { appService } = useEnv(); const { setCheckOpenWithBooks } = useLibraryStore(); - const listenedOpenWithBooks = useRef(false); - const isFirstWindow = async () => { - const allWindows = await getAllWindows(); - const currentWindow = getCurrentWindow(); - const sortedWindows = allWindows.sort((a, b) => a.label.localeCompare(b.label)); - return sortedWindows[0]?.label === currentWindow.label; - }; + useEffect(() => { + if (!isTauriAppPlatform() || !appService) return; - const handleOpenWithFileUrl = async (urls: string[]) => { - console.log('Handle Open with URL:', urls); - const filePaths = []; - for (let url of urls) { - if (url.startsWith('file://')) { - if (appService?.isIOSApp) { - url = decodeURI(url); - } else { - url = decodeURI(url.replace('file://', '')); + const isFirstWindow = async () => { + const allWindows = await getAllWindows(); + const currentWindow = getCurrentWindow(); + const sortedWindows = allWindows.sort((a, b) => a.label.localeCompare(b.label)); + return sortedWindows[0]?.label === currentWindow.label; + }; + + const handle = async (urls: string[]) => { + const filePaths: string[] = []; + for (let url of urls) { + if (url.startsWith('file://')) { + url = appService?.isIOSApp ? decodeURI(url) : decodeURI(url.replace('file://', '')); + } + if (!/^(https?:|data:|blob:|readest:)/i.test(url)) { + filePaths.push(url); } } - if (!/^(https?:|data:|blob:)/i.test(url)) { - filePaths.push(url); - } - } - if (filePaths.length > 0) { + if (filePaths.length === 0) return; + const settings = useSettingsStore.getState().settings; if (appService?.hasWindow && settings.openBookInNewWindow) { if (await isFirstWindow()) { @@ -61,70 +56,16 @@ export function useOpenWithBooks() { setCheckOpenWithBooks(true); navigateToLibrary(router, `reload=${Date.now()}`); } - } - }; - - const initializeListeners = async () => { - return await addPluginListener( - 'native-bridge', - 'shared-intent', - (payload) => { - console.log('Received shared intent:', payload); - const { urls } = payload; - handleOpenWithFileUrl(urls); - }, - ); - }; - - useEffect(() => { - if (!isTauriAppPlatform() || !appService) return; - if (listenedOpenWithBooks.current) return; - listenedOpenWithBooks.current = true; - - // For Windows/Linux deep link and macOS open-file event - const unlistenDeeplink = getCurrentWindow().listen( - 'single-instance', - ({ payload }) => { - console.log('Received deep link:', payload); - const { args } = payload; - if (args?.[1]) { - handleOpenWithFileUrl([args[1]]); - } - }, - ); - - // macOS in-app open-files event - const unlistenOpenFiles = getCurrentWindow().listen( - 'open-files', - ({ payload }) => { - console.log('Received open files:', payload); - const { files } = payload; - if (files && files.length > 0) { - handleOpenWithFileUrl(files); - } - }, - ); - - // For Android "Share to Readest" intent - let unlistenSharedIntent: Promise | null = null; - // FIXME: register/unregister plugin listeniner on iOS might cause app freeze for unknown reason - // so we only register it on Android for now to support "Shared to Readest" feature - if (appService?.isAndroidApp) { - unlistenSharedIntent = initializeListeners(); - } - - // iOS Open with URL event - const listenOpenWithFiles = async () => { - return await onOpenUrl((urls) => { - handleOpenWithFileUrl(urls); - }); }; - const unlistenOpenUrl = listenOpenWithFiles(); + + const onIncoming = (event: CustomEvent) => { + const { urls } = event.detail as { urls: string[] }; + handle(urls); + }; + eventDispatcher.on('app-incoming-url', onIncoming); + return () => { - unlistenDeeplink.then((f) => f()); - unlistenOpenFiles.then((f) => f()); - unlistenOpenUrl.then((f) => f()); - unlistenSharedIntent?.then((f) => f.unregister()); + eventDispatcher.off('app-incoming-url', onIncoming); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [appService]); diff --git a/apps/readest-app/src/store/readerStore.ts b/apps/readest-app/src/store/readerStore.ts index 3448f991..98c60a52 100644 --- a/apps/readest-app/src/store/readerStore.ts +++ b/apps/readest-app/src/store/readerStore.ts @@ -41,7 +41,13 @@ interface ViewState { ttsEnabled: boolean; syncing: boolean; gridInsets: Insets | null; - /* View settings for the view: + /* True while the reader is showing a position requested by an external + deep link (e.g. ?cfi=...) that the user hasn't yet confirmed by reading. + Progress writers (auto-save, cloud sync, kosync) skip while this is true + so the user's actual last-read position isn't overwritten by a preview. + Cleared on the first user-initiated relocate (page turn / scroll). */ + previewMode: boolean; + /* View settings for the view: generally view settings have a hierarchy of global settings < book settings < view settings view settings for primary view are saved to book config which is persisted to config file omitting settings that are not changed from global settings */ @@ -87,6 +93,7 @@ interface ReaderStore { getGridInsets: (key: string) => Insets | null; setGridInsets: (key: string, insets: Insets | null) => void; setViewInited: (key: string, inited: boolean) => void; + setPreviewMode: (key: string, previewMode: boolean) => void; recreateViewer: (envConfig: EnvConfigType, key: string) => void; } @@ -145,6 +152,7 @@ export const useReaderStore = create((set, get) => ({ ttsEnabled: false, syncing: false, gridInsets: null, + previewMode: false, viewSettings: null, }, }, @@ -274,6 +282,7 @@ export const useReaderStore = create((set, get) => ({ ttsEnabled: false, syncing: false, gridInsets: null, + previewMode: false, viewSettings: { ...globalViewSettings, ...configViewSettings }, }, }, @@ -297,6 +306,7 @@ export const useReaderStore = create((set, get) => ({ ttsEnabled: false, syncing: false, gridInsets: null, + previewMode: false, viewSettings: null, }, }, @@ -476,6 +486,17 @@ export const useReaderStore = create((set, get) => ({ }, })), + setPreviewMode: (key: string, previewMode: boolean) => + set((state) => ({ + viewStates: { + ...state.viewStates, + [key]: { + ...state.viewStates[key]!, + previewMode, + }, + }, + })), + recreateViewer: (envConfig: EnvConfigType, key: string) => { const id = key.split('-')[0]!; get()