feat(library): send book file from bookshelf selection popup (#4402)
* feat(library): send book file from bookshelf selection popup
Adds a Send button to the bottom popup that appears when one or more
books are selected on the bookshelf. Hands the actual book file
(epub/pdf/...) to the OS share sheet via tauri-plugin-sharekit
(UIActivityViewController on iOS, Intent.ACTION_SEND on Android,
NSSharingServicePicker on macOS), so users can fire the file off to
Mail / Messages / WeChat / AirDrop / etc.
This is intentionally distinct from the per-item context-menu "Share
Book", 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.
Resolution rules mirror bookContent.resolveBookContentSource: managed
copy under Books/<hash>/ first, then the device-local in-place import
path. Cloud-only books warn rather than silently no-op.
Path is handed to shareFile via options.filePath. Without that,
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 entire
epub/pdf into memory.
On macOS the NSSharingServicePicker is anchored to the selected
book's cover rather than to the Send button — the user's visual
focus is on the cover they just tapped, not on the bottom toolbar.
BookshelfItem stamps a data-book-hash attribute on its root div so
the Send handler can locate the cell via querySelector and pass its
rect through saveFile's sharePosition option. preferredEdge='bottom'
maps to NSMinYEdge, so the popover renders above the cover (and only
auto-flips below when there's no room above). iOS / Android share
sheets are modal and ignore sharePosition, so the same code is a
no-op there.
The button is hidden on Linux (no system share sheet), Windows
(WebView2 share UI deadlocks the main thread, see #4343), and web
browsers (no "send file to <app>" affordance for arbitrary downloads).
* fix(share): don't fall back to saveDialog when shareFile is cancelled
The native saveFile({ share: true }) path used to swallow any error
from sharekit's shareFile() and fall through to saveDialog. The plugin
treats user cancellation the same as a failure (it rejects with
'Share cancelled' on Android when the user dismisses the share sheet),
so cancelling a share popped up an unwanted 'Save As...' dialog right
after the user explicitly chose not to share.
Mirror what webAppService already does for the navigator.share()
AbortError path: once we entered the share branch, return true
regardless of whether the share completed or was cancelled. The
saveDialog path is now reserved for Linux/Windows desktop, which never
hit the share branch in the first place (wantShare gates them out).
If a future caller wants 'try share, fall back to save on hard
failure', that decision belongs to the caller — saveFile shouldn't
silently override an explicit share intent.
This commit is contained in:
@@ -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<BookshelfProps> = ({
|
||||
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<HTMLElement>(`[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/<hash>/ 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 <app>" 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<BookshelfProps> = ({
|
||||
<SelectModeActions
|
||||
selectedBooks={selectedBooks}
|
||||
safeAreaBottom={safeAreaInsets?.bottom || 0}
|
||||
// Native send targets: iOS, Android, macOS — route through
|
||||
// tauri-plugin-sharekit (UIActivityViewController /
|
||||
// Intent.ACTION_SEND / NSSharingServicePicker). Linux has no
|
||||
// system share sheet, Windows WebView2 share UI is disabled
|
||||
// upstream (issue #4343 — deadlocks the main thread), and web
|
||||
// browsers don't expose a real "send file to <app>" 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)}
|
||||
/>
|
||||
|
||||
@@ -427,6 +427,14 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
// 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 (
|
||||
<div className={clsx(mode === 'grid' ? 'h-full' : 'sm:hover:bg-base-300/50 px-4 sm:px-6')}>
|
||||
<div
|
||||
@@ -445,6 +453,7 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
|
||||
transition: 'transform 0.2s',
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
{...itemDataAttrs}
|
||||
{...handlers}
|
||||
>
|
||||
<div className='flex h-full flex-col justify-end'>
|
||||
|
||||
@@ -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<SelectModeActionsProps> = ({
|
||||
selectedBooks,
|
||||
safeAreaBottom,
|
||||
sendEnabled = true,
|
||||
onOpen,
|
||||
onGroup,
|
||||
onDetails,
|
||||
onStatus,
|
||||
onSend,
|
||||
onDelete,
|
||||
onCancel,
|
||||
}) => {
|
||||
@@ -96,11 +110,30 @@ const SelectModeActions: React.FC<SelectModeActionsProps> = ({
|
||||
<MdInfoOutline />
|
||||
<div>{_('Details')}</div>
|
||||
</button>
|
||||
{sendEnabled && (
|
||||
<button
|
||||
onClick={onSend}
|
||||
className={clsx(
|
||||
'flex flex-col items-center justify-center gap-1',
|
||||
// Wraps to the start of the second row on narrow viewports.
|
||||
'max-[500px]:col-start-1',
|
||||
(!hasSingleSelection || !hasValidBooks) && 'btn-disabled opacity-50',
|
||||
)}
|
||||
>
|
||||
<IoShareSocialOutline />
|
||||
<div>{_('Send')}</div>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className={clsx(
|
||||
'flex flex-col items-center justify-center gap-1',
|
||||
'max-[500px]:col-start-2',
|
||||
// Without Send (Linux/Windows/web), Delete needs an explicit
|
||||
// col-start-2 so the wrapped row {Delete, Cancel} stays centred
|
||||
// under the 4-col grid. With Send present, the layout is
|
||||
// {Send, Delete, Cancel} starting at col-start-1, so Delete
|
||||
// naturally lands in col-start-2 without an override.
|
||||
!sendEnabled && 'max-[500px]:col-start-2',
|
||||
!hasSelection && 'btn-disabled opacity-50',
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -667,10 +667,17 @@ export class NativeAppService extends BaseAppService {
|
||||
// WebView's top-left corner.
|
||||
...(options?.sharePosition ? { position: options.sharePosition } : {}),
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('shareFile failed; falling back to saveDialog:', error);
|
||||
// The plugin throws on user cancellation (e.g. dismissing the
|
||||
// Android share sheet returns "Share cancelled"). That's not a
|
||||
// failure — the user explicitly chose not to share, so we must
|
||||
// NOT fall back to saveDialog and pop a "Save As..." prompt.
|
||||
// Same goes for any other share error: the caller asked for a
|
||||
// share sheet, fulfilled or not, the saveDialog flow is a
|
||||
// completely different user intent.
|
||||
console.warn('shareFile did not complete:', error);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const filePath = await saveDialog({
|
||||
|
||||
Reference in New Issue
Block a user