feat(lightbox): save photo to Photos app on mobile via Web Share (#531)
@Jasper2213 reported non-technical clients struggle to get downloaded
photos into their Photos / Gallery app — current flow goes through the
Files folder, requires unzipping for the bulk download, and is hard to
explain over email. Browsers can't write directly to the OS Photos app
(it's a protected location), but navigator.share({ files: [...] }) opens
the native share sheet which on iOS includes "Save Image" and on Android
includes "Save to Photos" / "Save image" — exactly the affordance non-
technical users are looking for.
Plumbed through three layers:
1. galleryService — new savePhotoToDevice(slug, photoId, filename).
Fetches the photo blob, probes navigator.canShare({ files: [file] })
with a representative File (some browsers return true for empty
files arrays even when they won't accept a non-empty one), and:
- shares if supported,
- falls back to the existing <a download> path otherwise.
AbortError on share() means the user dismissed the sheet — that's
a choice, not a failure, so no fallback. Any other error falls
through to a regular download so the user still gets the file.
Refactored the existing downloadPhoto to share the fetch + trigger
helpers (no behaviour change for the other 3 callers; they keep
the regular download path).
2. useGallery — new useSavePhotoToDevice() hook next to the existing
useDownloadPhoto(). Onsuccess toast omitted because the share-sheet
path doesn't finish from this code's perspective — the OS UI takes
over and the user picks the destination, so "Photo downloaded" is
misleading. Fallback path stays silent to keep the two flows
symmetrical (the file appearing in Downloads is its own signal).
3. PhotoLightbox — swap the existing useDownloadPhoto call site to
useSavePhotoToDevice. No UI change. Desktop unchanged. Other
download buttons (PhotoGrid, PhotoGridWithLayouts, GalleryView
bulk) still use useDownloadPhoto — scoping this PR to the
lightbox download button per the discussion thread.
Browser support:
- iOS Safari 15+: Web Share Files → "Save Image" → Photos ✓
- Chrome Android: Web Share Files → "Save to Photos" / "Save" ✓
- Desktop Chrome: canShare returns false → regular download ✓
- Desktop Safari: canShare returns false → regular download ✓
- Firefox (any): no Web Share File support → regular download ✓
No new tests — the flow is browser-API-driven; jsdom doesn't model
navigator.share or canShare, so a meaningful unit test would mostly
exercise the mock rather than the contract. Verified the build is
clean (tsc --noEmit + vite build both pass).
Refs: #531
This commit is contained in:
@@ -2,7 +2,7 @@ import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useDevToolsProtection } from '../../hooks/useDevToolsProtection';
|
||||
import { X, ChevronLeft, ChevronRight, Download, ZoomIn, ZoomOut, MessageSquare, Heart, Star } from 'lucide-react';
|
||||
import type { Photo } from '../../types';
|
||||
import { useDownloadPhoto } from '../../hooks/useGallery';
|
||||
import { useSavePhotoToDevice } from '../../hooks/useGallery';
|
||||
import { AuthenticatedImage } from '../common';
|
||||
import { PhotoFeedback } from './PhotoFeedback';
|
||||
import { feedbackService } from '../../services/feedback.service';
|
||||
@@ -99,7 +99,12 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
}, []);
|
||||
|
||||
|
||||
const downloadPhotoMutation = useDownloadPhoto();
|
||||
// Save-aware download. On mobile (where Web Share + files is supported)
|
||||
// this opens the OS share sheet so "Save to Photos" actually lands in
|
||||
// the Photos/Gallery app — matters for non-technical clients who
|
||||
// otherwise have to chain Files → unzip → save (#531). Desktop and
|
||||
// unsupported browsers fall through to a regular <a download>.
|
||||
const downloadPhotoMutation = useSavePhotoToDevice();
|
||||
const currentPhoto = photos[currentIndex];
|
||||
|
||||
// DevTools protection - enabled by individual setting OR legacy protection level
|
||||
|
||||
@@ -65,6 +65,35 @@ export const useDownloadPhoto = () => {
|
||||
});
|
||||
};
|
||||
|
||||
// Save-aware download — opens the OS share sheet on mobile (so "Save to
|
||||
// Photos" lands the file in the Photos/Gallery app instead of Files),
|
||||
// falls back to a regular download on browsers without Web Share file
|
||||
// support. See galleryService.savePhotoToDevice for the negotiation
|
||||
// (#531).
|
||||
//
|
||||
// Toast omitted on success because the share-sheet path doesn't really
|
||||
// finish from this code's perspective — the OS UI takes over and the
|
||||
// user picks where it goes. Showing "Photo downloaded" before they've
|
||||
// even picked is misleading. The fallback download path is also silent
|
||||
// to keep the two paths symmetrical; the file appearing in Downloads
|
||||
// is its own affordance.
|
||||
export const useSavePhotoToDevice = () => {
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
slug,
|
||||
photoId,
|
||||
filename,
|
||||
}: {
|
||||
slug: string;
|
||||
photoId: number;
|
||||
filename: string;
|
||||
}) => galleryService.savePhotoToDevice(slug, photoId, filename),
|
||||
onError: () => {
|
||||
toast.error('Failed to save photo');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useDownloadAllPhotos = () => {
|
||||
return useMutation({
|
||||
mutationFn: ({ slug, zipReady }: { slug: string; zipReady?: boolean }) =>
|
||||
|
||||
@@ -48,43 +48,106 @@ export const galleryService = {
|
||||
};
|
||||
},
|
||||
|
||||
// Download single photo
|
||||
async downloadPhoto(slug: string, photoId: number, filename: string): Promise<void> {
|
||||
// Honour the server's Content-Disposition filename so the #493
|
||||
// "use original camera filename" toggle reaches disk for single
|
||||
// downloads (it already worked for zips because those skip the
|
||||
// `<a download>` attribute). Falls back to the caller-provided
|
||||
// sanitized filename if the header is unreadable.
|
||||
const downloadFromResponse = (response: { data: Blob; headers: Record<string, string> }) => {
|
||||
// Save single photo via the Web Share API on mobile, falling back to a
|
||||
// regular browser download elsewhere (#531).
|
||||
//
|
||||
// On iOS Safari 15+ and Chrome Android the OS share sheet opened by
|
||||
// navigator.share() includes "Save Image" / "Save to Photos", which
|
||||
// is what non-technical clients actually want — straight into the
|
||||
// Photos / Gallery app instead of the Files folder. Desktop browsers
|
||||
// and Firefox don't implement Web Share File support, so they get the
|
||||
// existing <a download> path (file lands in Downloads, same as before).
|
||||
async savePhotoToDevice(slug: string, photoId: number, filename: string): Promise<void> {
|
||||
const fetched = await this.fetchPhotoBlob(slug, photoId);
|
||||
const resolvedFilename = fetched.serverFilename || filename;
|
||||
|
||||
// canShare() returns false on browsers without Web Share file support
|
||||
// (desktop, older Safari, all Firefox as of writing). Probe with a
|
||||
// representative File so the negotiation is accurate — `canShare({
|
||||
// files: [] })` returns true on some browsers that don't actually
|
||||
// accept files at share() time.
|
||||
const file = new File([fetched.blob], resolvedFilename, {
|
||||
type: fetched.blob.type || 'image/jpeg',
|
||||
});
|
||||
const canShareFile =
|
||||
typeof navigator !== 'undefined' &&
|
||||
typeof navigator.canShare === 'function' &&
|
||||
navigator.canShare({ files: [file] });
|
||||
|
||||
if (canShareFile) {
|
||||
try {
|
||||
await navigator.share({ files: [file], title: resolvedFilename });
|
||||
return;
|
||||
} catch (err) {
|
||||
// AbortError = user dismissed the share sheet. Don't fall back —
|
||||
// they made a choice. Any other failure (NotAllowedError,
|
||||
// DataError, etc.) is unexpected; surface a download instead so
|
||||
// the user still gets the file.
|
||||
if ((err as DOMException)?.name === 'AbortError') return;
|
||||
}
|
||||
}
|
||||
|
||||
this.triggerBrowserDownload(fetched.blob, resolvedFilename);
|
||||
},
|
||||
|
||||
// Fetch the photo as a Blob + the server-suggested filename, falling
|
||||
// back to the view endpoint when the original isn't available. Shared
|
||||
// between the regular download flow and the Web Share path (#531).
|
||||
// The server's Content-Disposition is the source of truth for the
|
||||
// filename (#493 — "use original camera filename" toggle reaches disk
|
||||
// through this header).
|
||||
async fetchPhotoBlob(
|
||||
slug: string,
|
||||
photoId: number,
|
||||
): Promise<{ blob: Blob; serverFilename: string | null }> {
|
||||
const readResponse = (response: { data: Blob; headers: Record<string, string> }) => {
|
||||
const headerName =
|
||||
response.headers['content-disposition'] || response.headers['Content-Disposition'];
|
||||
const serverFilename = parseContentDispositionFilename(headerName);
|
||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.setAttribute('download', serverFilename || filename);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
return {
|
||||
blob: response.data,
|
||||
serverFilename: parseContentDispositionFilename(headerName),
|
||||
};
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await api.get(`/gallery/${slug}/download/${photoId}`, {
|
||||
responseType: 'blob',
|
||||
});
|
||||
downloadFromResponse(response);
|
||||
return readResponse(response);
|
||||
} catch {
|
||||
// Fallback: use the view endpoint if direct download fails (e.g., missing original).
|
||||
// The view endpoint doesn't emit a download-oriented Content-Disposition,
|
||||
// so we expect the caller-supplied filename to win here.
|
||||
// Fallback: view endpoint when /download isn't available (e.g.
|
||||
// the original is missing and only a derivative remains). The
|
||||
// view endpoint doesn't emit a download-oriented Content-Disposition,
|
||||
// so serverFilename will be null and the caller's name wins.
|
||||
const response = await api.get(`/gallery/${slug}/photo/${photoId}`, {
|
||||
responseType: 'blob',
|
||||
});
|
||||
downloadFromResponse(response);
|
||||
return readResponse(response);
|
||||
}
|
||||
},
|
||||
|
||||
// Trigger a regular browser download via a transient <a download>
|
||||
// anchor. Extracted from downloadPhoto so the share-fallback path
|
||||
// can reuse it without re-fetching the blob.
|
||||
triggerBrowserDownload(blob: Blob, filename: string): void {
|
||||
const url = window.URL.createObjectURL(new Blob([blob]));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.setAttribute('download', filename);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
},
|
||||
|
||||
// Download single photo — kept as the canonical name for the existing
|
||||
// grid + lightbox-action callers that haven't been migrated to the
|
||||
// share-aware savePhotoToDevice path yet.
|
||||
async downloadPhoto(slug: string, photoId: number, filename: string): Promise<void> {
|
||||
const fetched = await this.fetchPhotoBlob(slug, photoId);
|
||||
this.triggerBrowserDownload(fetched.blob, fetched.serverFilename || filename);
|
||||
},
|
||||
|
||||
// Download all photos as ZIP
|
||||
// When a pre-generated zip is available, use native browser download (Content-Length → progress bar).
|
||||
// Otherwise fall back to blob download.
|
||||
|
||||
Reference in New Issue
Block a user