Files
readest/apps/readest-app/src/libs/shareImport.ts
T
Huang Xin f5657fb3a0 fix(share): correct recipient import flow and assorted UI polish (#4043)
- ensureSharedBookLocal helper makes sure the local library has both the
  Book entry and the bytes on disk after /import succeeds; navigating
  into the reader before this lands on "Book not found"
- ShareLanding navigates via navigateToReader (path form on web) so the
  reader actually renders instead of hitting the App Router stub and
  going blank
- Loading + progress UI on the landing page while bytes stream in;
  Open-in-app disabled mid-import to avoid races
- UserInfo header: vertically center avatar with name/email, tighter
  mobile gap, and a fillContainer prop on UserAvatar so a parent can
  size the box via classes without the inline style fighting back
- Rename "Share current page" -> "Share reading progress" (and matching
  post-generation hint) and shrink the dialog from 480 to 460px
- Drop the unused Reload Page menu item from SettingsMenu
- Translate the two new i18n keys across 31 locales

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 16:16:46 +02:00

127 lines
5.7 KiB
TypeScript

import type { Book } from '@/types/book';
import type { AppService } from '@/types/system';
import { useLibraryStore } from '@/store/libraryStore';
import { getAPIBaseUrl } from '@/services/environment';
import { ShareApiError, getShare, type ImportShareResponse, type ShareMetadata } from './share';
interface EnsureSharedBookLocalArgs {
token: string;
importResult: ImportShareResponse;
appService: AppService;
/** Optional cached share metadata to avoid an extra GET on the new-import branch. */
meta?: ShareMetadata;
/** Progress callback for byte transfers (0-100). */
onProgress?: (percent: number) => void;
}
/**
* After `importShare(token)` succeeds (server-side R2 byte-copy done), make
* sure the local library has BOTH the Book entry AND the bytes on local fs,
* so opening the reader at the shared book works without a "Book not found"
* error.
*
* Three branches:
* - Book is in library and bytes are present on fs → no-op, return book.
* - Book is in library but bytes are missing locally (e.g. previously deleted
* or imported from another device) → `appService.downloadBook` pulls from
* the recipient's R2 namespace, which the import endpoint just populated.
* - Book is NOT in the local library → fetch bytes via the public share
* download endpoint, then run `appService.importBook` so a proper local
* Book entry is created (metadata, cover extraction, dir layout). Mark
* `uploadedAt` + `coverDownloadedAt` so transferManager doesn't re-upload
* bytes the server already has, and so the cover-pull doesn't redo what
* `importBook` just extracted from the book file.
*/
export const ensureSharedBookLocal = async ({
token,
importResult,
appService,
meta,
onProgress,
}: EnsureSharedBookLocalArgs): Promise<Book> => {
const storeState = useLibraryStore.getState();
const { setLibrary } = storeState;
// When the share landing runs this helper, `libraryLoaded` is false because
// /s/[token] doesn't mount useLibrary(). We load fresh from disk and only
// push the result back into the store if the store had already been hydrated
// by useLibrary somewhere else (e.g. /library, /reader, /opds). Otherwise we
// *must not* set libraryLoaded ourselves: useLibrary's init block loads BOTH
// the library AND `settings.globalReadSettings` in one go, and skips the
// whole block when libraryLoaded is already true. Setting it prematurely
// here leaves settings unloaded, and the Reader gate at Reader.tsx:167
// (`libraryLoaded && settings.globalReadSettings`) renders the empty
// fallback — exactly the blank-page symptom.
const wasLibraryLoaded = storeState.libraryLoaded;
const library = wasLibraryLoaded ? storeState.library : await appService.loadLibraryBooks();
const findByHash = (hash: string): Book | undefined =>
wasLibraryLoaded ? storeState.getBookByHash(hash) : library.find((b) => b.hash === hash);
const existing = findByHash(importResult.bookHash);
const persistLibrary = async () => {
await appService.saveLibraryBooks(library);
if (wasLibraryLoaded) setLibrary(library);
};
const reportProgress = onProgress
? (prog: { progress: number; total: number }) => {
if (prog.total > 0) onProgress(Math.floor((prog.progress / prog.total) * 100));
}
: undefined;
if (existing) {
const bytesPresent = !!existing.downloadedAt && (await appService.isBookAvailable(existing));
if (bytesPresent) return existing;
// Pull from recipient's namespace — the import endpoint already byte-copied
// both the book and the cover there. downloadBook handles missing-cover
// gracefully (covers may not exist) and sets downloadedAt internally.
await appService.downloadBook(existing, false, false, reportProgress);
// cloudService.downloadBook is silent on failure: if the cloud path
// mismatches the local Book's filename (e.g. share-import wrote bytes at
// the sharer's title, recipient's local Book has a different title) the
// function resolves without touching downloadedAt and the bytes are still
// absent. Verify before claiming success — otherwise the recipient
// navigates into the reader and hits "Book file not found".
if (!(await appService.isBookAvailable(existing))) {
throw new Error('Could not download shared book');
}
if (!existing.downloadedAt) existing.downloadedAt = Date.now();
existing.updatedAt = Date.now();
await persistLibrary();
return existing;
}
// Book is not in the local library yet. Fetch the bytes via the public share
// download endpoint (302 → presigned URL; fetch follows redirects), then
// hand them to importBook which knows how to create the proper local Book.
const shareMeta = meta ?? (await getShare(token));
const downloadUrl = `${getAPIBaseUrl()}/share/${encodeURIComponent(token)}/download`;
const response = await fetch(downloadUrl);
if (!response.ok) {
throw new ShareApiError(
response.status,
undefined,
response.statusText || 'Could not download shared book',
);
}
const blob = await response.blob();
const filename = `${shareMeta.title}.${shareMeta.format.toLowerCase()}`;
const file = new File([blob], filename, { type: blob.type || 'application/octet-stream' });
const imported = await appService.importBook(file, library);
if (!imported) {
throw new Error('Could not import shared book');
}
// The server already holds the book + cover bytes (R2 byte-copy in the
// import endpoint), so we tag uploadedAt/coverDownloadedAt to skip work
// transferManager and the cloud cover-pull would otherwise repeat.
const now = Date.now();
imported.uploadedAt = now;
if (!imported.downloadedAt) imported.downloadedAt = now;
imported.coverDownloadedAt = now;
await persistLibrary();
return imported;
};