feat(lightbox): multi-photo Web Share save-to-Photos on iOS (#557)
Extends #531 to the selection-based bulk-download flow. On iOS with a selection at or under MAX_WEB_SHARE_FILES (25), galleryService .downloadSelectedPhotos now routes through navigator.share({ files }) so the photos land directly in Photos via the share sheet's "Save N Images" action. Above the cap, anywhere off-iOS, or on any failure, the existing server-side zip path runs unchanged. The 25-file cap is the empirically-safe ceiling: iOS Safari's share sheet starts choking beyond ~25–30 files, and every File materialises as an in-memory Blob before share() is invoked, so a 500-photo selection would buffer multiple GB on the device. trySaveMultipleToDevice exposes three outcomes: - 'shared' — share() resolved; flow ends - 'dismissed' — user cancelled (AbortError); flow ends without zip fallback so dismissal isn't silently overridden - 'fallback' — capability missing or unexpected failure; caller takes the zip path Partial shares are deliberately avoided: a single failed photo fetch collapses the whole selection back to the zip endpoint rather than sharing only the photos that resolved. All 4 grid callers (PhotoGrid, PhotoGridWithLayouts, GalleryStoryLayout, GalleryPremiumLayout) funnel through downloadSelectedPhotos, so no caller-side changes are needed. Android, desktop, Firefox, and "Download All" are untouched. Layers on top of #556 (iOS-only gating via isIOS()). Builds against the fix/android-download-web-share-554 branch.
This commit is contained in:
@@ -0,0 +1,204 @@
|
|||||||
|
/**
|
||||||
|
* Coverage for #557 — iOS Web Share path on multi-photo selection.
|
||||||
|
*
|
||||||
|
* downloadSelectedPhotos historically POSTed to /download-selected and
|
||||||
|
* triggered a zip download. On iOS with a small selection it now routes
|
||||||
|
* through navigator.share({ files }) so the photos land in Photos via
|
||||||
|
* the share sheet's "Save N Images" action. Above the file-count cap
|
||||||
|
* or anywhere off-iOS, behaviour is unchanged.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const IOS_UA = 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15';
|
||||||
|
const ANDROID_UA = 'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 Chrome/120.0.0.0';
|
||||||
|
|
||||||
|
const fetchedFor = (id: number) => ({
|
||||||
|
blob: new Blob([`photo-${id}`], { type: 'image/jpeg' }),
|
||||||
|
serverFilename: `IMG_${String(id).padStart(4, '0')}.jpg`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock the axios layer so the test never makes a network call. The
|
||||||
|
// real api.post resolves with { data: Blob } for the zip path; the
|
||||||
|
// shape only matters when the fallback branch is exercised.
|
||||||
|
vi.mock('../../config/api', () => ({
|
||||||
|
api: {
|
||||||
|
post: vi.fn(),
|
||||||
|
get: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
let galleryService: typeof import('../gallery.service').galleryService;
|
||||||
|
let apiMock: { post: ReturnType<typeof vi.fn>; get: ReturnType<typeof vi.fn> };
|
||||||
|
|
||||||
|
const installNavigator = (overrides: Partial<{
|
||||||
|
userAgent: string;
|
||||||
|
platform: string;
|
||||||
|
maxTouchPoints: number;
|
||||||
|
share: ReturnType<typeof vi.fn>;
|
||||||
|
canShare: ReturnType<typeof vi.fn>;
|
||||||
|
}>) => {
|
||||||
|
const desc = (value: any) => ({ value, configurable: true, writable: true });
|
||||||
|
Object.defineProperties(navigator, {
|
||||||
|
userAgent: desc(overrides.userAgent ?? ''),
|
||||||
|
platform: desc(overrides.platform ?? ''),
|
||||||
|
maxTouchPoints: desc(overrides.maxTouchPoints ?? 0),
|
||||||
|
});
|
||||||
|
(navigator as any).share = overrides.share;
|
||||||
|
(navigator as any).canShare = overrides.canShare;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('galleryService.downloadSelectedPhotos — iOS Web Share path (#557)', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.resetModules();
|
||||||
|
const services = await import('../gallery.service');
|
||||||
|
galleryService = services.galleryService;
|
||||||
|
apiMock = (await import('../../config/api')).api as any;
|
||||||
|
apiMock.post.mockReset();
|
||||||
|
apiMock.post.mockResolvedValue({ data: new Blob(['zip-bytes'], { type: 'application/zip' }) });
|
||||||
|
|
||||||
|
// jsdom doesn't ship URL.createObjectURL / revokeObjectURL —
|
||||||
|
// the zip-fallback path needs both to materialise the <a> link.
|
||||||
|
(window.URL.createObjectURL as any) = vi.fn(() => 'blob:fake');
|
||||||
|
(window.URL.revokeObjectURL as any) = vi.fn();
|
||||||
|
|
||||||
|
// fetchPhotoBlob is the network-dependent helper; stub it across
|
||||||
|
// every test so we never touch the real download endpoint.
|
||||||
|
vi.spyOn(galleryService, 'fetchPhotoBlob').mockImplementation((_slug, id) =>
|
||||||
|
Promise.resolve(fetchedFor(id) as any),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
delete (navigator as any).share;
|
||||||
|
delete (navigator as any).canShare;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes through navigator.share on iOS with a small selection', async () => {
|
||||||
|
const share = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const canShare = vi.fn().mockReturnValue(true);
|
||||||
|
installNavigator({ userAgent: IOS_UA, share, canShare });
|
||||||
|
|
||||||
|
await galleryService.downloadSelectedPhotos('wedding-2026', [1, 2, 3]);
|
||||||
|
|
||||||
|
expect(share).toHaveBeenCalledTimes(1);
|
||||||
|
const shareArg = share.mock.calls[0][0];
|
||||||
|
expect(shareArg.files).toHaveLength(3);
|
||||||
|
expect((shareArg.files[0] as File).name).toBe('IMG_0001.jpg');
|
||||||
|
// Zip endpoint must NOT be called when share succeeds.
|
||||||
|
expect(apiMock.post).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the zip endpoint on Android, even with canShare available', async () => {
|
||||||
|
const share = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const canShare = vi.fn().mockReturnValue(true);
|
||||||
|
installNavigator({ userAgent: ANDROID_UA, share, canShare });
|
||||||
|
|
||||||
|
await galleryService.downloadSelectedPhotos('wedding-2026', [1, 2, 3]);
|
||||||
|
|
||||||
|
expect(share).not.toHaveBeenCalled();
|
||||||
|
expect(apiMock.post).toHaveBeenCalledTimes(1);
|
||||||
|
expect(apiMock.post).toHaveBeenCalledWith(
|
||||||
|
'/gallery/wedding-2026/download-selected',
|
||||||
|
{ photo_ids: [1, 2, 3] },
|
||||||
|
{ responseType: 'blob' },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the zip endpoint above the 25-file cap', async () => {
|
||||||
|
// 26 photos: even on iOS, this exceeds MAX_WEB_SHARE_FILES so the
|
||||||
|
// Web Share path is skipped entirely (no fetchPhotoBlob calls,
|
||||||
|
// no share() call, no canShare() probe).
|
||||||
|
const share = vi.fn();
|
||||||
|
const canShare = vi.fn();
|
||||||
|
installNavigator({ userAgent: IOS_UA, share, canShare });
|
||||||
|
|
||||||
|
const ids = Array.from({ length: 26 }, (_, i) => i + 1);
|
||||||
|
await galleryService.downloadSelectedPhotos('wedding-2026', ids);
|
||||||
|
|
||||||
|
expect(galleryService.fetchPhotoBlob).not.toHaveBeenCalled();
|
||||||
|
expect(share).not.toHaveBeenCalled();
|
||||||
|
expect(apiMock.post).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('takes the Web Share path at exactly the 25-file boundary', async () => {
|
||||||
|
const share = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const canShare = vi.fn().mockReturnValue(true);
|
||||||
|
installNavigator({ userAgent: IOS_UA, share, canShare });
|
||||||
|
|
||||||
|
const ids = Array.from({ length: 25 }, (_, i) => i + 1);
|
||||||
|
await galleryService.downloadSelectedPhotos('wedding-2026', ids);
|
||||||
|
|
||||||
|
expect(share).toHaveBeenCalledTimes(1);
|
||||||
|
expect(apiMock.post).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT fall back to the zip endpoint when the user dismisses the share sheet (AbortError)', async () => {
|
||||||
|
const abortErr = Object.assign(new Error('user dismissed'), { name: 'AbortError' });
|
||||||
|
const share = vi.fn().mockRejectedValue(abortErr);
|
||||||
|
const canShare = vi.fn().mockReturnValue(true);
|
||||||
|
installNavigator({ userAgent: IOS_UA, share, canShare });
|
||||||
|
|
||||||
|
await galleryService.downloadSelectedPhotos('wedding-2026', [1, 2, 3]);
|
||||||
|
|
||||||
|
expect(share).toHaveBeenCalledTimes(1);
|
||||||
|
// A surprise zip in Downloads would defeat the user's deliberate
|
||||||
|
// dismissal of the share sheet.
|
||||||
|
expect(apiMock.post).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the zip endpoint when share() rejects with a non-Abort error', async () => {
|
||||||
|
const notAllowed = Object.assign(new Error('blocked'), { name: 'NotAllowedError' });
|
||||||
|
const share = vi.fn().mockRejectedValue(notAllowed);
|
||||||
|
const canShare = vi.fn().mockReturnValue(true);
|
||||||
|
installNavigator({ userAgent: IOS_UA, share, canShare });
|
||||||
|
|
||||||
|
await galleryService.downloadSelectedPhotos('wedding-2026', [1, 2, 3]);
|
||||||
|
|
||||||
|
expect(apiMock.post).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the zip endpoint when canShare({files}) returns false (older iOS)', async () => {
|
||||||
|
const share = vi.fn();
|
||||||
|
const canShare = vi.fn().mockReturnValue(false);
|
||||||
|
installNavigator({ userAgent: IOS_UA, share, canShare });
|
||||||
|
|
||||||
|
await galleryService.downloadSelectedPhotos('wedding-2026', [1, 2, 3]);
|
||||||
|
|
||||||
|
expect(share).not.toHaveBeenCalled();
|
||||||
|
expect(apiMock.post).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the zip endpoint when any photo fetch fails (partial shares would be confusing)', async () => {
|
||||||
|
const share = vi.fn();
|
||||||
|
const canShare = vi.fn().mockReturnValue(true);
|
||||||
|
installNavigator({ userAgent: IOS_UA, share, canShare });
|
||||||
|
|
||||||
|
// Re-stub fetchPhotoBlob so the 2nd of 3 photos fails — the Promise.all
|
||||||
|
// collapse must route the whole batch to the zip endpoint rather
|
||||||
|
// than sharing only the photos that resolved.
|
||||||
|
(galleryService.fetchPhotoBlob as any).mockReset();
|
||||||
|
(galleryService.fetchPhotoBlob as any)
|
||||||
|
.mockResolvedValueOnce(fetchedFor(1))
|
||||||
|
.mockRejectedValueOnce(new Error('network'))
|
||||||
|
.mockResolvedValueOnce(fetchedFor(3));
|
||||||
|
|
||||||
|
await galleryService.downloadSelectedPhotos('wedding-2026', [1, 2, 3]);
|
||||||
|
|
||||||
|
expect(share).not.toHaveBeenCalled();
|
||||||
|
expect(apiMock.post).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the zip endpoint when the selection is empty (no Web Share invocation)', async () => {
|
||||||
|
const share = vi.fn();
|
||||||
|
const canShare = vi.fn();
|
||||||
|
installNavigator({ userAgent: IOS_UA, share, canShare });
|
||||||
|
|
||||||
|
await galleryService.downloadSelectedPhotos('wedding-2026', []);
|
||||||
|
|
||||||
|
expect(galleryService.fetchPhotoBlob).not.toHaveBeenCalled();
|
||||||
|
expect(share).not.toHaveBeenCalled();
|
||||||
|
expect(apiMock.post).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -22,6 +22,14 @@ function isIOS(): boolean {
|
|||||||
return navigator.platform === 'MacIntel' && (navigator.maxTouchPoints || 0) > 1;
|
return navigator.platform === 'MacIntel' && (navigator.maxTouchPoints || 0) > 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hard cap on the multi-file Web Share path (#557). iOS Safari's share
|
||||||
|
// sheet starts to choke and silently fail beyond ~25–30 files in
|
||||||
|
// practice; equally important, every File materialises as an in-memory
|
||||||
|
// Blob before share() is invoked, so a 500-photo @ 10 MB selection
|
||||||
|
// would buffer 5 GB on the device. Above this cap we fall through to
|
||||||
|
// the existing server-side zip flow.
|
||||||
|
const MAX_WEB_SHARE_FILES = 25;
|
||||||
|
|
||||||
export const galleryService = {
|
export const galleryService = {
|
||||||
// Verify share token
|
// Verify share token
|
||||||
async verifyToken(slug: string, token: string): Promise<{ valid: boolean }> {
|
async verifyToken(slug: string, token: string): Promise<{ valid: boolean }> {
|
||||||
@@ -221,8 +229,24 @@ export const galleryService = {
|
|||||||
window.URL.revokeObjectURL(url);
|
window.URL.revokeObjectURL(url);
|
||||||
},
|
},
|
||||||
|
|
||||||
// Download selected photos as ZIP
|
// Download selected photos. On iOS with a small selection, route
|
||||||
|
// through Web Share so the files land directly in Photos via the
|
||||||
|
// share sheet's "Save N Images" action (#557, extending #531 to the
|
||||||
|
// multi-photo case). Above the cap, or anywhere else, fall through
|
||||||
|
// to the existing server-side zip flow.
|
||||||
async downloadSelectedPhotos(slug: string, photoIds: number[]): Promise<void> {
|
async downloadSelectedPhotos(slug: string, photoIds: number[]): Promise<void> {
|
||||||
|
if (
|
||||||
|
isIOS() &&
|
||||||
|
photoIds.length > 0 &&
|
||||||
|
photoIds.length <= MAX_WEB_SHARE_FILES
|
||||||
|
) {
|
||||||
|
const status = await this.trySaveMultipleToDevice(slug, photoIds);
|
||||||
|
// 'shared' = share() resolved; 'dismissed' = user closed the
|
||||||
|
// share sheet — both terminate the flow without touching the
|
||||||
|
// zip path. Only 'fallback' continues below.
|
||||||
|
if (status !== 'fallback') return;
|
||||||
|
}
|
||||||
|
|
||||||
const response = await api.post(`/gallery/${slug}/download-selected`, { photo_ids: photoIds }, {
|
const response = await api.post(`/gallery/${slug}/download-selected`, { photo_ids: photoIds }, {
|
||||||
responseType: 'blob',
|
responseType: 'blob',
|
||||||
});
|
});
|
||||||
@@ -237,6 +261,53 @@ export const galleryService = {
|
|||||||
window.URL.revokeObjectURL(url);
|
window.URL.revokeObjectURL(url);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// iOS-only Web Share path for a selection of photos.
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// 'shared' — navigator.share resolved; files are now in the OS share sheet
|
||||||
|
// 'dismissed' — user cancelled the share sheet (AbortError); do NOT fall back
|
||||||
|
// 'fallback' — capability missing or unexpected failure; caller should
|
||||||
|
// use the server-side zip path instead
|
||||||
|
//
|
||||||
|
// Callers must gate by isIOS() + count <= MAX_WEB_SHARE_FILES before
|
||||||
|
// invoking this; the method does not re-check those conditions.
|
||||||
|
async trySaveMultipleToDevice(
|
||||||
|
slug: string,
|
||||||
|
photoIds: number[],
|
||||||
|
): Promise<'shared' | 'dismissed' | 'fallback'> {
|
||||||
|
let fetched: Array<{ blob: Blob; serverFilename: string | null }>;
|
||||||
|
try {
|
||||||
|
// Parallel fetch — modern browsers cap at ~6 connections per origin
|
||||||
|
// on HTTP/1.1, unlimited on HTTP/2, so 25 concurrent requests is
|
||||||
|
// safe without an explicit semaphore. A single failed fetch
|
||||||
|
// collapses the whole selection back to the zip path; partial
|
||||||
|
// shares would leave the user wondering which photos were saved.
|
||||||
|
fetched = await Promise.all(photoIds.map((id) => this.fetchPhotoBlob(slug, id)));
|
||||||
|
} catch {
|
||||||
|
return 'fallback';
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = fetched.map((entry, idx) => {
|
||||||
|
const name = entry.serverFilename || `photo-${photoIds[idx]}.jpg`;
|
||||||
|
return new File([entry.blob], name, { type: entry.blob.type || 'image/jpeg' });
|
||||||
|
});
|
||||||
|
|
||||||
|
const canShareFiles =
|
||||||
|
typeof navigator !== 'undefined' &&
|
||||||
|
typeof navigator.canShare === 'function' &&
|
||||||
|
navigator.canShare({ files });
|
||||||
|
|
||||||
|
if (!canShareFiles) return 'fallback';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await navigator.share({ files });
|
||||||
|
return 'shared';
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as DOMException)?.name === 'AbortError') return 'dismissed';
|
||||||
|
return 'fallback';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// Toggle photo visibility (client-only)
|
// Toggle photo visibility (client-only)
|
||||||
async togglePhotoVisibility(slug: string, photoId: number, visibility: 'visible' | 'hidden'): Promise<void> {
|
async togglePhotoVisibility(slug: string, photoId: number, visibility: 'visible' | 'hidden'): Promise<void> {
|
||||||
await api.patch(`/gallery/${slug}/photos/${photoId}/visibility`, { visibility });
|
await api.patch(`/gallery/${slug}/photos/${photoId}/visibility`, { visibility });
|
||||||
|
|||||||
Reference in New Issue
Block a user