diff --git a/apps/readest-app/src/app/library/components/Bookshelf.tsx b/apps/readest-app/src/app/library/components/Bookshelf.tsx index 87b5347e..02098877 100644 --- a/apps/readest-app/src/app/library/components/Bookshelf.tsx +++ b/apps/readest-app/src/app/library/components/Bookshelf.tsx @@ -45,6 +45,9 @@ import { resolveEffectiveSecondarySort, } from '../utils/libraryUtils'; import { eventDispatcher } from '@/utils/event'; +import { getLocalBookFilename } from '@/utils/book'; +import { MIMETYPES, EXTS } from '@/libs/document'; +import { makeSafeFilename } from '@/utils/misc'; import { useSpatialNavigation } from '../hooks/useSpatialNavigation'; import Alert from '@/components/Alert'; @@ -425,6 +428,114 @@ const Bookshelf: React.FC = ({ setShowStatusAlert(true); }; + const sendSelectedBook = async () => { + // "Send" hands the actual book file (epub/pdf/...) to the OS share + // sheet (UIActivityViewController on iOS, Intent.ACTION_SEND on + // Android, NSSharingServicePicker on macOS) so the user can fire it + // off to Mail / Messages / WeChat / AirDrop / etc. Backed by + // tauri-plugin-sharekit via appService.saveFile({ share: true }). + // + // This is intentionally distinct from the per-item "Share Book" + // context menu, which uploads the book to the readest backend and + // generates a public link. "Send" is offline file egress; "Share + // Book" is remote collaboration. They share zero infra. + // + // Linux has no system share sheet, and Windows is intentionally + // disabled (issue #4343 — WebView2's native share UI blocks the main + // thread waiting on cancel/complete callbacks that may never fire). + // We hide the button entirely on those platforms (see sendEnabled + // in the JSX) so users don't see an action that can't be honoured. + + const ids = getSelectedBooks(); + if (ids.length !== 1) return; + const book = filteredBooks.find((b) => b.hash === ids[0]); + if (!book || !appService) return; + + // Anchor the macOS share popover to the selected book's cover, not + // to the Send button — the user just tapped/clicked the book, so + // their visual focus is on the cover. We look the cover up via the + // `data-book-hash` attribute that BookshelfItem stamps on its root + // div. The rect must be captured *before* setShowSelectModeActions + // tears the popup down (the bookshelf itself stays mounted, but we + // still want to grab it up front to keep the share-call site + // simple). preferredEdge='bottom' maps to NSMinYEdge, which in + // WKWebView's flipped coord space is the rect's top edge, so the + // popover renders above the cover (and only auto-flips below when + // there's no room above). On iOS / Android the share sheet is modal + // and ignores sharePosition, so this work is harmless there. + const coverEl = document.querySelector(`[data-book-hash="${book.hash}"]`); + const anchorRect = coverEl?.getBoundingClientRect(); + const sharePosition = anchorRect + ? { + x: anchorRect.left + anchorRect.width / 2, + y: anchorRect.top + anchorRect.height / 2, + preferredEdge: 'bottom' as const, + } + : undefined; + + setShowSelectModeActions(false); + handleSetSelectMode(false); + + try { + // Resolve the file the same way bookContent.resolveBookContentSource + // does, but via the public AppService surface (the underlying `fs` + // is protected): managed copy under Books// first, then the + // device-local in-place import path. Cloud-only books or remote + // URL books can't be shared without first downloading them. + const managedPath = getLocalBookFilename(book); + let path: string; + let base: 'Books' | 'None'; + if (await appService.exists(managedPath, 'Books')) { + path = managedPath; + base = 'Books'; + } else if (book.filePath && (await appService.exists(book.filePath, 'None'))) { + path = book.filePath; + base = 'None'; + } else { + eventDispatcher.dispatch('toast', { + type: 'warning', + message: _('Book file is not available locally'), + timeout: 2500, + }); + return; + } + const ext = EXTS[book.format] ?? 'bin'; + const mimeType = MIMETYPES[book.format]?.[0] ?? 'application/octet-stream'; + const baseName = makeSafeFilename(book.sourceTitle || book.title || book.hash); + const shareFilename = `${baseName}.${ext}`; + + // Native (Tauri) only — the Share button is hidden on web because + // browsers can't surface a real "share to " sheet for an + // arbitrary local file. Hand the already-on-disk file straight to + // the OS share sheet via `options.filePath`. Without it, + // saveFile() falls back to writing a temp copy under + // BaseDirectory.Temp, which on Android resolves to + // /data/local/tmp/ — the app sandbox has no write permission + // there and the call fails with EACCES ("failed to open file at + // path: /data/local/tmp/...epub Permission denied (os error + // 13)"). Passing the absolute path also avoids re-buffering the + // whole epub/pdf into memory just to have saveFile write it back + // to disk. + const absoluteFilePath = await appService.resolveFilePath(path, base); + // saveFile's binary branch wants an ArrayBuffer; with `filePath` + // set the content arg is ignored on the native share path, but + // we still pass an empty buffer so the type-checker is happy. + await appService.saveFile(shareFilename, new ArrayBuffer(0), { + share: true, + mimeType, + filePath: absoluteFilePath, + sharePosition, + }); + } catch (err) { + console.error('Failed to send book file:', err); + eventDispatcher.dispatch('toast', { + type: 'error', + message: _('Failed to send book'), + timeout: 2500, + }); + } + }; + const updateBooksStatus = async (status: ReadingStatus | undefined) => { const selectedIds = getSelectedBooks(); const booksToUpdate: Book[] = []; @@ -688,10 +799,22 @@ const Bookshelf: React.FC = ({ " sheet, so + // the button is hidden on those platforms. + sendEnabled={ + !!appService && + (appService.isIOSApp || appService.isAndroidApp || appService.isMacOSApp) + } onOpen={openSelectedBooks} onGroup={groupSelectedBooks} onDetails={openBookDetails} onStatus={showStatusSelection} + onSend={sendSelectedBook} onDelete={deleteSelectedBooks} onCancel={() => handleSetSelectMode(false)} /> diff --git a/apps/readest-app/src/app/library/components/BookshelfItem.tsx b/apps/readest-app/src/app/library/components/BookshelfItem.tsx index 10f981e0..69075d64 100644 --- a/apps/readest-app/src/app/library/components/BookshelfItem.tsx +++ b/apps/readest-app/src/app/library/components/BookshelfItem.tsx @@ -427,6 +427,14 @@ const BookshelfItem: React.FC = ({ } }; + // Tag the rendered DOM with the book/group identity so feature code + // (e.g. the Send action's macOS share-popover anchor) can locate the + // exact bookshelf cell the user is acting on without threading refs + // through every parent. Books carry their content-hash; groups carry + // their full group name. + const itemDataAttrs = + 'format' in item ? { 'data-book-hash': item.hash } : { 'data-group-name': item.name }; + return (
= ({ transition: 'transform 0.2s', }} onKeyDown={handleKeyDown} + {...itemDataAttrs} {...handlers} >
diff --git a/apps/readest-app/src/app/library/components/SelectModeActions.tsx b/apps/readest-app/src/app/library/components/SelectModeActions.tsx index 62bf24b3..bc0a4e0d 100644 --- a/apps/readest-app/src/app/library/components/SelectModeActions.tsx +++ b/apps/readest-app/src/app/library/components/SelectModeActions.tsx @@ -6,6 +6,7 @@ import { MdInfoOutline, MdCheckCircleOutline, } from 'react-icons/md'; +import { IoShareSocialOutline } from 'react-icons/io5'; import { LuFolderPlus } from 'react-icons/lu'; import { useKeyDownActions } from '@/hooks/useKeyDownActions'; import { useTranslation } from '@/hooks/useTranslation'; @@ -14,10 +15,21 @@ import { isMd5 } from '@/utils/md5'; interface SelectModeActionsProps { selectedBooks: string[]; safeAreaBottom: number; + // When false (Linux desktop, Windows desktop, web) the Send button is + // hidden entirely — those platforms can't surface a system share sheet + // so the affordance would be misleading. Note: this is *file send* (hands + // the book file to the OS share sheet), distinct from "Share Book" in + // the per-item context menu, which generates a remote share link. + sendEnabled?: boolean; onOpen: () => void; onGroup: () => void; onDetails: () => void; onStatus: () => void; + // The macOS / iPad share popover is anchored to the selected book's + // cover (located via its data-book-hash attribute), not to this + // button — the user's visual focus is on the cover they just tapped. + // On iOS / Android the share sheet is modal and ignores position. + onSend: () => void; onDelete: () => void; onCancel: () => void; } @@ -25,10 +37,12 @@ interface SelectModeActionsProps { const SelectModeActions: React.FC = ({ selectedBooks, safeAreaBottom, + sendEnabled = true, onOpen, onGroup, onDetails, onStatus, + onSend, onDelete, onCancel, }) => { @@ -96,11 +110,30 @@ const SelectModeActions: React.FC = ({
{_('Details')}
+ {sendEnabled && ( + + )}