forked from akai/readest
feat(annotations): preview mode for deep-link landings (#4019)
* feat(annotations): preview mode for deep-link landings
When the reader opens at a deep-link CFI (e.g. clicking an exported
highlight from Obsidian), the position should not be persisted as the
user's reading progress until they actually start reading. Otherwise
the deep-link visit overwrites their last-read position and propagates
that across all sync targets.
Adds a per-book `previewMode` flag in the reader store that:
- Is set to true in FoliateViewer when the URL's `?cfi=` overrides the
saved last-position.
- Is cleared on the first user-initiated relocate (page turn / scroll),
reusing the existing reason filter in `docRelocateHandler`.
- Gates the auto progress writers:
- useProgressAutoSave — skip local config persist
- useProgressSync — skip auto-push and skip the remote-progress
view.goTo (so cloud pull doesn't yank the
user away from the previewed annotation)
- useKOSync — skip auto-push (manual pushes still respected)
Hardcover sync and Discord presence are unaffected: hardcover only
fires on explicit user button press, and Discord presence carries no
position information.
Also picks up the regenerated AndroidManifest.xml change from the
existing tauri.conf.json deep-link config (registers readest:// scheme
on Android so the smart landing page's intent:// launch resolves).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(annotations): jump in place when target book is already open
When an annotation deep link arrives while the user is already in the
reader (most common case on mobile App Links), navigateToReader was
pushing the same /reader path with a different cfi query param. The
reader's init useEffect has [] deps, so it doesn't re-run, and
FoliateViewer doesn't re-read the cfi — the view stayed put.
Detect a mounted view for the target book hash by walking
viewStates and matching the hash prefix on the bookKey. If found,
call view.goTo(cfi) directly and set previewMode so the existing
gates fire. Falls back to navigateToReader when no view is open.
Also adds a console.log on each parsed deep link to make this path
easier to debug from device logs in the future.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -31,6 +31,7 @@ const nextConfig = {
|
||||
assetPrefix: '',
|
||||
reactStrictMode: true,
|
||||
serverExternalPackages: ['isows'],
|
||||
allowedDevOrigins: ['192.168.2.120'],
|
||||
webpack: (config) => {
|
||||
config.resolve.alias = {
|
||||
...config.resolve.alias,
|
||||
|
||||
@@ -4,5 +4,16 @@
|
||||
<dict>
|
||||
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||
<true/>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>com.bilingify.readest</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>readest</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -107,12 +107,22 @@
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="https" />
|
||||
<data android:scheme="http" />
|
||||
<data android:host="web.readest.com" />
|
||||
|
||||
|
||||
|
||||
|
||||
</intent-filter>
|
||||
<intent-filter >
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="readest" />
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</intent-filter>
|
||||
<!-- DEEP LINK PLUGIN. AUTO-GENERATED. DO NOT REMOVE. -->
|
||||
</activity>
|
||||
|
||||
@@ -84,6 +84,7 @@ function seedViewState(key: string, overrides: Record<string, unknown> = {}) {
|
||||
ttsEnabled: false,
|
||||
syncing: false,
|
||||
gridInsets: null,
|
||||
previewMode: false,
|
||||
viewSettings: null,
|
||||
...overrides,
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -146,7 +146,7 @@ const OpenAnnotationLanding = () => {
|
||||
// Invalid link — missing book or note identifier.
|
||||
if (!bookHash || !noteId) {
|
||||
return (
|
||||
<main className='bg-base-200 flex min-h-screen flex-col items-center justify-center p-4 sm:p-8'>
|
||||
<main className='bg-base-200 flex min-h-dvh flex-col items-center justify-center p-4 sm:p-8'>
|
||||
<Card>
|
||||
<div className='flex flex-col items-center text-center'>
|
||||
<div className='bg-base-200 mb-4 flex h-16 w-16 items-center justify-center rounded-2xl'>
|
||||
@@ -174,7 +174,7 @@ const OpenAnnotationLanding = () => {
|
||||
const webReaderHref = buildWebReaderUrl(bookHash, cfi);
|
||||
|
||||
return (
|
||||
<main className='bg-base-200 flex min-h-screen flex-col items-center justify-center p-4 sm:p-8'>
|
||||
<main className='bg-base-200 flex min-h-dvh flex-col items-center justify-center p-4 sm:p-8'>
|
||||
<Card>
|
||||
<BrandHeader
|
||||
title={_('Open in Readest')}
|
||||
|
||||
@@ -94,6 +94,7 @@ const FoliateViewer: React.FC<{
|
||||
const { settings } = useSettingsStore();
|
||||
const { loadFont, loadCustomFonts, getLoadedFonts, getAvailableFonts } = useCustomFontStore();
|
||||
const { getView, setView: setFoliateView, setViewInited, setProgress } = useReaderStore();
|
||||
const setPreviewMode = useReaderStore((s) => 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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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<SingleInstancePayload>(
|
||||
'single-instance',
|
||||
({ payload }) => {
|
||||
const url = payload.args?.[1];
|
||||
if (url) dispatch([url]);
|
||||
},
|
||||
);
|
||||
|
||||
const unlistenOpenFiles = getCurrentWindow().listen<OpenFilesPayload>(
|
||||
'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<PluginListener> | null = null;
|
||||
if (appService?.isAndroidApp) {
|
||||
unlistenSharedIntent = addPluginListener<SharedIntentPayload>(
|
||||
'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]);
|
||||
}
|
||||
@@ -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<AnnotationDeepLink | null>(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<SingleInstancePayload>(
|
||||
'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]);
|
||||
}
|
||||
|
||||
@@ -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<SharedIntentPayload>(
|
||||
'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<SingleInstancePayload>(
|
||||
'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<OpenFilesPayload>(
|
||||
'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<PluginListener> | 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]);
|
||||
|
||||
@@ -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<ReaderStore>((set, get) => ({
|
||||
ttsEnabled: false,
|
||||
syncing: false,
|
||||
gridInsets: null,
|
||||
previewMode: false,
|
||||
viewSettings: null,
|
||||
},
|
||||
},
|
||||
@@ -274,6 +282,7 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
|
||||
ttsEnabled: false,
|
||||
syncing: false,
|
||||
gridInsets: null,
|
||||
previewMode: false,
|
||||
viewSettings: { ...globalViewSettings, ...configViewSettings },
|
||||
},
|
||||
},
|
||||
@@ -297,6 +306,7 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
|
||||
ttsEnabled: false,
|
||||
syncing: false,
|
||||
gridInsets: null,
|
||||
previewMode: false,
|
||||
viewSettings: null,
|
||||
},
|
||||
},
|
||||
@@ -476,6 +486,17 @@ export const useReaderStore = create<ReaderStore>((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()
|
||||
|
||||
Reference in New Issue
Block a user