From 51ee88cc20c5f49c13ec002510069cebb2c2241c Mon Sep 17 00:00:00 2001 From: "xan.lol" Date: Fri, 9 Oct 2026 02:20:40 -0700 Subject: [PATCH] name media downloads after their posts - probably won't work for images downloaded via CDNs - also mention avatar or banner for the corresponding images --- src/components/Lightbox/Lightbox.tsx | 4 +- src/components/Lightbox/Lightbox.web.tsx | 2 + src/components/Lightbox/pager/ImagePager.tsx | 12 ++- src/components/Lightbox/types.ts | 1 + src/components/MediaPreview.tsx | 39 +++++++-- .../Post/Embed/ImageContextMenu.tsx | 4 +- src/components/Post/Embed/ImageEmbed.tsx | 9 +- .../Post/Embed/VideoEmbed/index.web.tsx | 6 +- .../Post/Embed/VideoEmbed/useVideoDownload.ts | 8 +- .../PostControls/PostMenu/PostMenuItems.tsx | 11 ++- src/components/StarterPack/ShareDialog.tsx | 10 ++- src/components/images/Gallery/index.tsx | 7 +- src/components/images/ImageLayoutGrid.tsx | 6 +- src/components/images/ImageLayoutGridItem.tsx | 5 +- src/lib/media/downloadFilename.test.ts | 61 ++++++++++++++ src/lib/media/downloadFilename.ts | 49 +++++++++++ src/lib/media/manip.download.test.ts | 82 +++++++++++++++++++ src/lib/media/manip.ts | 61 +++++++++++--- src/lib/media/manip.web.test.ts | 60 +++++++++++++- src/lib/media/manip.web.ts | 39 +++++++-- src/lib/media/save-image.ios.ts | 3 +- src/lib/media/save-image.ts | 3 +- src/lib/media/saveVideoToDevice.ts | 10 ++- src/lib/media/saveVideoToDevice.web.ts | 10 ++- src/screens/Profile/Header/Shell.tsx | 2 + .../notifications/NotificationFeedItem.tsx | 1 + 26 files changed, 454 insertions(+), 51 deletions(-) create mode 100644 src/lib/media/downloadFilename.test.ts create mode 100644 src/lib/media/downloadFilename.ts create mode 100644 src/lib/media/manip.download.test.ts diff --git a/src/components/Lightbox/Lightbox.tsx b/src/components/Lightbox/Lightbox.tsx index 200721414..65c33f66f 100644 --- a/src/components/Lightbox/Lightbox.tsx +++ b/src/components/Lightbox/Lightbox.tsx @@ -19,7 +19,9 @@ export function Lightbox() { void saveImageToAlbum(uri, format)} + onPressSave={(uri, format, downloadName) => + void saveImageToAlbum(uri, format, downloadName) + } onPressShare={uri => void shareImageModal({uri})} /> ) diff --git a/src/components/Lightbox/Lightbox.web.tsx b/src/components/Lightbox/Lightbox.web.tsx index 334617162..bf5d3deed 100644 --- a/src/components/Lightbox/Lightbox.web.tsx +++ b/src/components/Lightbox/Lightbox.web.tsx @@ -318,6 +318,7 @@ function LightboxGallery({ onPress={() => { saveImageToMediaLibrary({ uri: img.uri, + downloadName: img.downloadName, format: downloadFormat, }).then( () => Toast.show(l`Download started`), @@ -350,6 +351,7 @@ function LightboxGallery({ onPress={() => { saveImageToMediaLibrary({ uri: img.uri, + downloadName: img.downloadName, format: value, }).then( () => Toast.show(l`Download started`), diff --git a/src/components/Lightbox/pager/ImagePager.tsx b/src/components/Lightbox/pager/ImagePager.tsx index 376cf290f..74934e9d0 100644 --- a/src/components/Lightbox/pager/ImagePager.tsx +++ b/src/components/Lightbox/pager/ImagePager.tsx @@ -85,7 +85,7 @@ export default function ImageViewRoot({ }: { lightbox: Lightbox | null onRequestClose: () => void - onPressSave: (uri: string, format: string) => void + onPressSave: (uri: string, format: string, downloadName?: string) => void onPressShare: (uri: string) => void }) { 'use no memo' @@ -228,7 +228,7 @@ function ImageView({ setImageIndex: React.Dispatch> orientation: 'portrait' | 'landscape' onRequestClose: () => void - onPressSave: (uri: string, format: string) => void + onPressSave: (uri: string, format: string, downloadName?: string) => void onPressShare: (uri: string) => void onFlyAway: () => void safeAreaRef: AnimatedRef @@ -438,7 +438,13 @@ function ImageView({
onPressShare(images[imageIndex].uri)} - onPressSave={format => onPressSave(images[imageIndex].uri, format)} + onPressSave={format => + onPressSave( + images[imageIndex].uri, + format, + images[imageIndex].downloadName, + ) + } imageCount={images.length} activeIndex={imageIndex} /> diff --git a/src/components/Lightbox/types.ts b/src/components/Lightbox/types.ts index db6891745..653f29a87 100644 --- a/src/components/Lightbox/types.ts +++ b/src/components/Lightbox/types.ts @@ -23,6 +23,7 @@ export type Position = { } export type ImageSource = { + downloadName?: string uri: string dimensions: Dimensions | null thumbUri: string diff --git a/src/components/MediaPreview.tsx b/src/components/MediaPreview.tsx index 347bf9f89..269d7d476 100644 --- a/src/components/MediaPreview.tsx +++ b/src/components/MediaPreview.tsx @@ -2,6 +2,7 @@ import {type StyleProp, StyleSheet, View, type ViewStyle} from 'react-native' import {Image} from 'expo-image' import {Trans, useLingui} from '@lingui/react/macro' +import {getPostMediaDownloadName} from '#/lib/media/downloadFilename' import {shareImageModal} from '#/lib/media/manip' import {useSaveImageToMediaLibrary} from '#/lib/media/save-image' import {isGifEmbed} from '#/lib/strings/embed-player' @@ -27,9 +28,11 @@ export function Embed({ embed, style, peekable = false, + post, }: { embed: app.bsky.feed.defs.PostView['embed'] style?: StyleProp + post?: {uri: string; author: {handle: string}} peekable?: boolean }) { const e = bsky.post.parseEmbed(embed) @@ -39,9 +42,16 @@ export function Embed({ if (e.type === 'images') { return ( - {e.view.images.map(image => + {e.view.images.map((image, index) => peekable ? ( - + 1 ? index : undefined, + )} + /> ) : ( ) + tiles.push( + 1 ? tiles.length : undefined, + )} + />, + ) } else { tiles.push( ) @@ -241,7 +262,13 @@ export function VideoItem({ ) } -function PeekableImageItem({image}: {image: app.bsky.embed.images.ViewImage}) { +function PeekableImageItem({ + image, + downloadName, +}: { + image: app.bsky.embed.images.ViewImage + downloadName?: string +}) { const {t: l} = useLingui() const saveImage = useSaveImageToMediaLibrary() @@ -265,7 +292,9 @@ function PeekableImageItem({image}: {image: app.bsky.embed.images.ViewImage}) { void saveImage(image.fullsize)}> + onSelect={() => + void saveImage(image.fullsize, undefined, downloadName) + }> {l`Save image`} diff --git a/src/components/Post/Embed/ImageContextMenu.tsx b/src/components/Post/Embed/ImageContextMenu.tsx index 8273fcfc8..fe7f9c890 100644 --- a/src/components/Post/Embed/ImageContextMenu.tsx +++ b/src/components/Post/Embed/ImageContextMenu.tsx @@ -26,6 +26,7 @@ import {IS_IOS} from '#/env' */ export function ImageContextMenu({ fullsizeUri, + downloadName, thumbUri, aspectRatio, borderRadius, @@ -33,6 +34,7 @@ export function ImageContextMenu({ style, children, }: { + downloadName?: string fullsizeUri: string /** Thumbnail URL. Used as an instant placeholder in the native preview * while the fullsize loads, so there's no black flash on first peek. */ @@ -57,7 +59,7 @@ export function ImageContextMenu({ } const handleSave = () => { - void saveImage(fullsizeUri) + void saveImage(fullsizeUri, undefined, downloadName) } const handleShare = () => { void shareImageModal({uri: fullsizeUri}) diff --git a/src/components/Post/Embed/ImageEmbed.tsx b/src/components/Post/Embed/ImageEmbed.tsx index 67a96bf7e..92bb771a5 100644 --- a/src/components/Post/Embed/ImageEmbed.tsx +++ b/src/components/Post/Embed/ImageEmbed.tsx @@ -3,6 +3,7 @@ import {View} from 'react-native' import {type AnimatedRef} from 'react-native-reanimated' import {Image} from 'expo-image' +import {getPostMediaDownloadName} from '#/lib/media/downloadFilename' import {resolveEmbedImageUris} from '#/lib/media/embed-image-formats' import {getRecordImageMimeType} from '#/lib/media/recordImageMimeType' import {getShapeRadius} from '#/lib/shapes' @@ -59,7 +60,7 @@ export function ImageEmbed({ aspectRatio: item.aspectRatio, })) : embed.view.images - const images = rawImages.map(img => { + const images = rawImages.map((img, index) => { const uris = resolveEmbedImageUris( { ...img, @@ -73,6 +74,10 @@ export function ImageEmbed({ ) return { ...img, + downloadName: getPostMediaDownloadName( + rest.post, + rawImages.length > 1 ? index : undefined, + ), fullsize: uris.fullsize as typeof img.fullsize, thumb: uris.thumb as typeof img.thumb, } @@ -106,6 +111,7 @@ export function ImageEmbed({ return { uri: applyImageTransforms(img.fullsize, {imageCdnHost}), thumbUri: applyImageTransforms(img.thumb, {imageCdnHost}), + downloadName: img.downloadName, alt: img.alt, dimensions: img.aspectRatio ?? null, } @@ -162,6 +168,7 @@ export function ImageEmbed({ (null) const { active: activeFromContext, diff --git a/src/components/Post/Embed/VideoEmbed/useVideoDownload.ts b/src/components/Post/Embed/VideoEmbed/useVideoDownload.ts index 24d6c2a34..2bc89c542 100644 --- a/src/components/Post/Embed/VideoEmbed/useVideoDownload.ts +++ b/src/components/Post/Embed/VideoEmbed/useVideoDownload.ts @@ -1,6 +1,7 @@ import {isDid} from '@atproto/api' import {useLingui} from '@lingui/react/macro' +import {getPostMediaDownloadName} from '#/lib/media/downloadFilename' import {saveVideoToDevice} from '#/lib/media/saveVideoToDevice' import {resolvePdsServiceUrl} from '#/state/queries/resolve-identity' import * as Toast from '#/components/Toast' @@ -8,8 +9,10 @@ import * as Toast from '#/components/Toast' export function useVideoDownload({ did, cid, + post, }: { did: string | undefined + post?: {uri: string; author: {handle: string}} cid: string }) { const {t: l} = useLingui() @@ -23,7 +26,10 @@ export function useVideoDownload({ try { const pdsUrl = await resolvePdsServiceUrl(did) const uri = `${pdsUrl}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${cid}` - const success = await saveVideoToDevice({uri}) + const success = await saveVideoToDevice({ + uri, + downloadName: getPostMediaDownloadName(post), + }) Toast.show( success diff --git a/src/components/PostControls/PostMenu/PostMenuItems.tsx b/src/components/PostControls/PostMenu/PostMenuItems.tsx index aa13d37be..2a178c9ab 100644 --- a/src/components/PostControls/PostMenu/PostMenuItems.tsx +++ b/src/components/PostControls/PostMenu/PostMenuItems.tsx @@ -24,6 +24,7 @@ import {useQueryClient} from '@tanstack/react-query' import {DISCOVER_DEBUG_DIDS} from '#/lib/constants' import {useOpenComposer} from '#/lib/hooks/useOpenComposer' import {useOpenLink} from '#/lib/hooks/useOpenLink' +import {getPostMediaDownloadName} from '#/lib/media/downloadFilename' import {saveVideoToDevice} from '#/lib/media/saveVideoToDevice' import {getRedraftImages} from '#/lib/redraft' import {getCurrentRoute} from '#/lib/routes/helpers' @@ -667,7 +668,10 @@ let PostMenuItems = ({ Toast.show(l({message: 'Downloading video...', context: 'toast'})) - const success = await saveVideoToDevice({uri}) + const success = await saveVideoToDevice({ + uri, + downloadName: getPostMediaDownloadName(post), + }) Toast.show( success @@ -683,7 +687,10 @@ let PostMenuItems = ({ Toast.show(l({message: 'Downloading GIF...', context: 'toast'})) let success - success = await saveVideoToDevice({uri: gifEmbed.external.uri}) + success = await saveVideoToDevice({ + uri: gifEmbed.external.uri, + downloadName: getPostMediaDownloadName(post), + }) if (success) Toast.show(l({message: 'GIF downloaded', context: 'toast'}), { diff --git a/src/components/StarterPack/ShareDialog.tsx b/src/components/StarterPack/ShareDialog.tsx index 8db64dfb2..dc8175bae 100644 --- a/src/components/StarterPack/ShareDialog.tsx +++ b/src/components/StarterPack/ShareDialog.tsx @@ -4,6 +4,7 @@ import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' import {Trans} from '@lingui/react/macro' +import {getPostMediaDownloadName} from '#/lib/media/downloadFilename' import {useSaveImageToMediaLibrary} from '#/lib/media/save-image' import {shareUrl} from '#/lib/sharing' import {getStarterPackOgCard} from '#/lib/strings/starter-pack' @@ -67,7 +68,14 @@ function ShareDialogInner({ const saveImageToAlbum = useSaveImageToMediaLibrary() const onSave = async () => { - await saveImageToAlbum(imageUrl) + await saveImageToAlbum( + imageUrl, + undefined, + getPostMediaDownloadName({ + uri: starterPack.uri, + author: starterPack.creator, + }), + ) } return ( diff --git a/src/components/images/Gallery/index.tsx b/src/components/images/Gallery/index.tsx index 0a00d32a3..4152f0e9b 100644 --- a/src/components/images/Gallery/index.tsx +++ b/src/components/images/Gallery/index.tsx @@ -18,6 +18,7 @@ import {utils} from '@bsky.app/alf' import {Trans, useLingui} from '@lingui/react/macro' import debounce from 'lodash.debounce' +import {type DownloadableImage} from '#/lib/media/downloadFilename' import {type Dimensions} from '#/lib/media/types' import {mergeRefs} from '#/lib/merge-refs' import {getShapeRadius, SHARP_CORNERS} from '#/lib/shapes' @@ -48,13 +49,12 @@ import {PostEmbedViewContext} from '#/components/Post/Embed/types' import {Text} from '#/components/Typography' import {useAnalytics} from '#/analytics' import {IS_ANDROID, IS_WEB} from '#/env' -import {type app} from '#/lexicons' export * from './const' export * from './maybeApplyGalleryOffsetStyles' interface GalleryProps { - images: app.bsky.embed.images.ViewImage[] + images: DownloadableImage[] onPress?: ( index: number, containerRefs: AnimatedRef[], @@ -416,7 +416,7 @@ function GalleryImage({ onPreviewPress, }: { contentHeight: number - image: app.bsky.embed.images.ViewImage + image: DownloadableImage index: number imageCount: number onWidthChange: (index: number, width: number) => void @@ -458,6 +458,7 @@ function GalleryImage({ aria-label={image.alt || l`Image ${index + 1} of ${imageCount}`}> [], @@ -56,7 +56,7 @@ export function ImageLayoutGrid({ } interface ImageLayoutGridInnerProps { - images: app.bsky.embed.images.ViewImage[] + images: DownloadableImage[] onPress?: ( index: number, containerRefs: AnimatedRef[], diff --git a/src/components/images/ImageLayoutGridItem.tsx b/src/components/images/ImageLayoutGridItem.tsx index 6e0890321..ea1d884a0 100644 --- a/src/components/images/ImageLayoutGridItem.tsx +++ b/src/components/images/ImageLayoutGridItem.tsx @@ -6,6 +6,7 @@ import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' import {Trans} from '@lingui/react/macro' +import {type DownloadableImage} from '#/lib/media/downloadFilename' import {type Dimensions} from '#/lib/media/types' import {getShapeRadius, SHARP_CORNERS} from '#/lib/shapes' import {useEnableSquareButtons} from '#/state/preferences/enable-square-buttons' @@ -20,12 +21,11 @@ import {MediaInsetBorder} from '#/components/MediaInsetBorder' import {ImageContextMenu} from '#/components/Post/Embed/ImageContextMenu' import {type PostEmbedViewContext} from '#/components/Post/Embed/types' import {Text} from '#/components/Typography' -import {type app} from '#/lexicons' type EventFunction = (index: number) => void interface Props { - images: app.bsky.embed.images.ViewImage[] + images: DownloadableImage[] index: number onPress?: ( index: number, @@ -79,6 +79,7 @@ export function GalleryItem({ { + expect(getPostMediaDownloadName(post)).toBe('alice.example-3abc') + expect(getPostMediaDownloadName(post, 0)).toBe('alice.example-3abc-1') + expect(getPostMediaDownloadName(post, 1)).toBe('alice.example-3abc-2') +}) + +it('does not invent post context for other media', () => { + expect(getPostMediaDownloadName(undefined)).toBeUndefined() + expect( + getPostMediaDownloadName({...post, uri: 'not an at uri'}), + ).toBeUndefined() +}) + +it('keeps the actual file extension and removes unsafe filename characters', () => { + expect( + getMediaDownloadFilename({ + downloadName: '../alice:example-3abc/1', + uri: '', + extension: 'png', + kind: 'image', + }), + ).toBe('witchsky-_alice_example-3abc_1.png') + expect( + getMediaDownloadFilename({ + downloadName: getPostMediaDownloadName(post), + uri: '', + extension: 'mp4', + kind: 'video', + }), + ).toBe('witchsky-alice.example-3abc.mp4') +}) + +it.each([ + [ + 'image', + 'https://cdn.example/img/feed_fullsize/plain/did:plc:alice/bafyimage@jpeg', + 'png', + 'witchsky-image-bafyimage.png', + ], + [ + 'video', + 'https://pds.example/xrpc/com.atproto.sync.getBlob?did=did:plc:alice&cid=bafyvideo', + 'mp4', + 'witchsky-video-bafyvideo.mp4', + ], +] as const)( + 'uses a blob identity when %s post metadata is unavailable', + (kind, uri, extension, expected) => { + expect(getMediaDownloadFilename({uri, kind, extension})).toBe(expected) + }, +) diff --git a/src/lib/media/downloadFilename.ts b/src/lib/media/downloadFilename.ts new file mode 100644 index 000000000..9cd379a10 --- /dev/null +++ b/src/lib/media/downloadFilename.ts @@ -0,0 +1,49 @@ +import {AtUri} from '@atproto/syntax' + +import {getImageBlobRef} from '#/lib/media/util' +import {type app} from '#/lexicons' + +export type DownloadableImage = app.bsky.embed.images.ViewImage & { + downloadName?: string +} + +/** A filename stem shared by all download surfaces for the same post media. */ +export function getPostMediaDownloadName( + post: {uri: string; author: {handle: string}} | undefined, + index?: number, +) { + if (!post?.author.handle) return undefined + try { + const rkey = new AtUri(post.uri).rkey + if (!rkey) return undefined + return `${post.author.handle}-${rkey}${index === undefined ? '' : `-${index + 1}`}` + } catch { + return undefined + } +} + +/** Keeps filenames safe on all platforms and uses the downloaded file's type. */ +export function getMediaDownloadFilename({ + downloadName, + uri, + extension, + kind, +}: { + downloadName?: string + uri: string + extension: string + kind: 'image' | 'video' +}) { + let cid = getImageBlobRef(uri)?.cid + if (!cid) { + try { + cid = new URL(uri).searchParams.get('cid') ?? undefined + } catch {} + } + const fallback = `${kind}${cid ? `-${cid}` : ''}` + const name = (downloadName || fallback) + .replace(/[<>:"/\\|?*\u0000-\u001f]/g, '_') + .replace(/^[.\s]+|[.\s]+$/g, '') + .slice(0, 180) + return `witchsky-${name || kind}.${extension}` +} diff --git a/src/lib/media/manip.download.test.ts b/src/lib/media/manip.download.test.ts new file mode 100644 index 000000000..853bb59eb --- /dev/null +++ b/src/lib/media/manip.download.test.ts @@ -0,0 +1,82 @@ +import { + copyAsync, + createDownloadResumable, + deleteAsync, +} from 'expo-file-system/legacy' +import {saveToLibraryAsync} from 'expo-media-library/legacy' + +import { + saveImageToMediaLibrary, + saveVideoToMediaLibrary, +} from '#/lib/media/manip' + +jest.mock('#/logger', () => ({logger: {error: jest.fn()}})) +jest.mock('#/env', () => ({IS_ANDROID: false, IS_IOS: true, IS_NATIVE: true})) +jest.mock('#/lib/media/original-image', () => ({ + getDownloadImageUri: (uri: string) => Promise.resolve(uri), +})) +jest.mock('expo-file-system/legacy', () => ({ + cacheDirectory: 'file:///cache/', + copyAsync: jest.fn().mockResolvedValue(undefined), + moveAsync: jest.fn().mockResolvedValue(undefined), + makeDirectoryAsync: jest.fn().mockResolvedValue(undefined), + deleteAsync: jest.fn().mockResolvedValue(undefined), + createDownloadResumable: jest.fn(), +})) + +beforeEach(() => jest.clearAllMocks()) + +it('saves original images with a descriptive basename and their downloaded MIME extension', async () => { + jest.mocked(createDownloadResumable).mockReturnValue({ + downloadAsync: () => + Promise.resolve({ + uri: 'file:///cache/download.bin', + mimeType: 'image/png', + }), + } as unknown as ReturnType) + await saveImageToMediaLibrary({ + uri: 'https://pds.example/image', + downloadName: 'alice.example-3abc-1', + }) + expect(saveToLibraryAsync).toHaveBeenCalledWith( + expect.stringMatching(/\/witchsky-alice\.example-3abc-1\.png$/), + ) + const path = jest.mocked(saveToLibraryAsync).mock.calls[0][0] + expect(deleteAsync).toHaveBeenCalledWith( + path.slice(0, path.lastIndexOf('/')), + {idempotent: true}, + ) +}) + +it('uses distinct staging folders for repeated filenames and cleans up failed saves', async () => { + jest.mocked(createDownloadResumable).mockReturnValue({ + downloadAsync: () => + Promise.resolve({ + uri: 'file:///cache/download.bin', + headers: {'content-type': 'video/mp4'}, + }), + } as unknown as ReturnType) + await saveVideoToMediaLibrary({ + uri: 'https://pds.example/video', + downloadName: 'alice.example-3abc', + }) + jest + .mocked(saveToLibraryAsync) + .mockRejectedValueOnce(new Error('save failed')) + await expect( + saveVideoToMediaLibrary({ + uri: 'https://pds.example/video', + downloadName: 'alice.example-3abc', + }), + ).rejects.toThrow('save failed') + const first = jest.mocked(saveToLibraryAsync).mock.calls[0][0] + const second = jest.mocked(saveToLibraryAsync).mock.calls[1][0] + expect(first).toMatch(/\/witchsky-alice\.example-3abc\.mp4$/) + expect(second).toMatch(/\/witchsky-alice\.example-3abc\.mp4$/) + expect(first).not.toBe(second) + expect(deleteAsync).toHaveBeenCalledWith( + second.slice(0, second.lastIndexOf('/')), + {idempotent: true}, + ) + expect(copyAsync).toHaveBeenCalledTimes(2) +}) diff --git a/src/lib/media/manip.ts b/src/lib/media/manip.ts index 7f41637e5..97b126257 100644 --- a/src/lib/media/manip.ts +++ b/src/lib/media/manip.ts @@ -16,6 +16,7 @@ import {SaveFormat} from 'expo-image-manipulator' import * as MediaLibrary from 'expo-media-library/legacy' import * as Sharing from 'expo-sharing' +import {getMediaDownloadFilename} from '#/lib/media/downloadFilename' import {formatToFileExt, imageMimeToExtension} from '#/lib/media/image-formats' import {getDownloadImageUri} from '#/lib/media/original-image' import {logger} from '#/logger' @@ -112,9 +113,11 @@ const ALBUM_NAME = 'Bluesky' export async function saveImageToMediaLibrary({ uri, format = 'original', + downloadName, }: { uri: string format?: string + downloadName?: string }) { const downloadUri = await getDownloadImageUri(uri, format) const downloadedPath = await downloadImage( @@ -127,7 +130,16 @@ export async function saveImageToMediaLibrary({ dotIndex >= 0 ? downloadedPath.slice(dotIndex) : `.${formatToFileExt(format)}` - const imagePath = await moveToPermanentPath(downloadedPath, ext) + const imagePath = await moveToPermanentPath( + downloadedPath, + ext, + getMediaDownloadFilename({ + downloadName, + uri, + extension: ext.slice(1), + kind: 'image', + }), + ) // save try { @@ -188,11 +200,17 @@ export async function saveImageToMediaLibrary({ }) throw err } finally { - void safeDeleteAsync(imagePath) + void safeDeleteAsync(imagePath.slice(0, imagePath.lastIndexOf('/'))) } } -export async function saveVideoToMediaLibrary({uri}: {uri: string}) { +export async function saveVideoToMediaLibrary({ + uri, + downloadName, +}: { + uri: string + downloadName?: string +}) { // download the file to cache const tempPath = `${cacheDirectory ?? ''}/${String(uuid.v4())}.bin` const dlResumable = createDownloadResumable(uri, tempPath, {cache: true}) @@ -214,7 +232,11 @@ export async function saveVideoToMediaLibrary({uri}: {uri: string}) { return false } - const videoPath = await moveToPermanentPath(dlRes.uri, '.' + extension) + const videoPath = await moveToPermanentPath( + dlRes.uri, + '.' + extension, + getMediaDownloadFilename({downloadName, uri, extension, kind: 'video'}), + ) // save try { @@ -234,7 +256,7 @@ export async function saveVideoToMediaLibrary({uri}: {uri: string}) { }) throw err } finally { - void safeDeleteAsync(videoPath) + void safeDeleteAsync(videoPath.slice(0, videoPath.lastIndexOf('/'))) } return true } @@ -339,7 +361,11 @@ async function doResize( } } -async function moveToPermanentPath(path: string, ext: string): Promise { +async function moveToPermanentPath( + path: string, + ext: string, + downloadFilename?: string, +): Promise { /* Since this package stores images in a temp directory, we need to move the file to a permanent location. Relevant: IOS bug when trying to open a second time: @@ -349,11 +375,24 @@ async function moveToPermanentPath(path: string, ext: string): Promise { // cacheDirectory will not ever be null on native, but it could be on web. This function only ever gets called on // native so we assert as a string. - const destinationPath = joinPath(cacheDirectory as string, filename + ext) - await copyAsync({ - from: normalizePath(path), - to: normalizePath(destinationPath), - }) + const directory = downloadFilename + ? joinPath(cacheDirectory as string, String(filename)) + : (cacheDirectory as string) + if (downloadFilename) + await makeDirectoryAsync(directory, {intermediates: true}) + const destinationPath = joinPath( + directory, + downloadFilename ?? filename + ext, + ) + try { + await copyAsync({ + from: normalizePath(path), + to: normalizePath(destinationPath), + }) + } catch (error) { + if (downloadFilename) await safeDeleteAsync(directory) + throw error + } void safeDeleteAsync(path) return normalizePath(destinationPath) } diff --git a/src/lib/media/manip.web.test.ts b/src/lib/media/manip.web.test.ts index 17b30075a..545f1b67d 100644 --- a/src/lib/media/manip.web.test.ts +++ b/src/lib/media/manip.web.test.ts @@ -4,7 +4,7 @@ jest.mock('#/state/queries/resolve-identity', () => ({ .mockResolvedValue('https://owner.pds.example'), })) -import {saveImageToMediaLibrary} from './manip.web' +import {downloadVideoWeb, saveImageToMediaLibrary} from './manip.web' const image = 'https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:example/bafyexample@jpeg' @@ -16,12 +16,23 @@ const anchor = { style: {display: ''}, click: jest.fn(), } +const createBefore = URL.createObjectURL +const revokeBefore = URL.revokeObjectURL const originalFetch = Object.getOwnPropertyDescriptor(globalThis, 'fetch')! -const fetchMock = jest.fn(() => Promise.reject(new TypeError('CORS blocked'))) +const fetchMock = jest.fn, [RequestInfo | URL]>(() => + Promise.reject(new TypeError('CORS blocked')), +) const originalDocument = Object.getOwnPropertyDescriptor(globalThis, 'document') const originalLocation = Object.getOwnPropertyDescriptor(window, 'location') beforeEach(() => { + jest.useFakeTimers() + URL.createObjectURL = jest.fn().mockReturnValue('blob:download') + URL.revokeObjectURL = jest.fn() + anchor.href = '' + anchor.download = '' + anchor.target = '' + anchor.rel = '' fetchMock.mockClear() Object.defineProperty(globalThis, 'fetch', { configurable: true, @@ -42,6 +53,10 @@ beforeEach(() => { }) afterEach(() => { + jest.runOnlyPendingTimers() + jest.useRealTimers() + URL.createObjectURL = createBefore + URL.revokeObjectURL = revokeBefore Object.defineProperty(globalThis, 'fetch', originalFetch) if (originalDocument) Object.defineProperty(globalThis, 'document', originalDocument) @@ -54,11 +69,16 @@ afterEach(() => { it.each(['avif', 'gif'])( 'downloads %s attachments without a CORS fetch', async format => { - await saveImageToMediaLibrary({uri: image, format}) + await saveImageToMediaLibrary({ + uri: image, + format, + downloadName: 'alice.example-3abc-1', + }) expect(fetch).not.toHaveBeenCalled() expect(anchor.href).toBe( `https://cdn.bsky.app/img/download/plain/did:plc:example/bafyexample@${format}`, ) + expect(anchor.download).toBe(`witchsky-alice.example-3abc-1.${format}`) expect(anchor.target).toBe('_blank') expect(anchor.rel).toBe('noopener noreferrer') expect(anchor.click).toHaveBeenCalledTimes(1) @@ -74,3 +94,37 @@ it('keeps original blobs on the fetch path and surfaces errors', async () => { ) expect(anchor.click).not.toHaveBeenCalled() }) + +it('names original image bytes by post and actual MIME type', async () => { + const blob = new Blob(['image'], {type: 'image/png'}) + fetchMock.mockResolvedValueOnce({ + ok: true, + blob: () => Promise.resolve(blob), + } as Response) + await saveImageToMediaLibrary({ + uri: image, + downloadName: 'alice.example-3abc', + }) + expect(anchor.href).toBe('blob:download') + expect(anchor.download).toBe('witchsky-alice.example-3abc.png') + jest.runOnlyPendingTimers() + expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:download') +}) + +it('names downloaded videos by post and releases the local blob', async () => { + const blob = new Blob(['video'], {type: 'video/mp4'}) + fetchMock.mockResolvedValueOnce({ + ok: true, + blob: () => Promise.resolve(blob), + } as Response) + await expect( + downloadVideoWeb({ + uri: 'https://pds.example/video', + downloadName: 'alice.example-3abc', + }), + ).resolves.toBe(true) + expect(anchor.download).toBe('witchsky-alice.example-3abc.mp4') + expect(anchor.click).toHaveBeenCalledTimes(1) + jest.runOnlyPendingTimers() + expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:download') +}) diff --git a/src/lib/media/manip.web.ts b/src/lib/media/manip.web.ts index 36a91a041..3faf74169 100644 --- a/src/lib/media/manip.web.ts +++ b/src/lib/media/manip.web.ts @@ -1,3 +1,4 @@ +import {getMediaDownloadFilename} from '#/lib/media/downloadFilename' import {formatToFileExt, imageMimeToExtension} from '#/lib/media/image-formats' import {getDownloadImageUri} from '#/lib/media/original-image' import {type PickerImage} from './picker.shared' @@ -52,9 +53,11 @@ export function shareImageModal(_opts: {uri: string}) { export async function saveImageToMediaLibrary({ uri, format = 'original', + downloadName, }: { uri: string format?: string + downloadName?: string }) { const downloadUri = await getDownloadImageUri(uri, format) const url = new URL(downloadUri, window.location.href) @@ -62,7 +65,16 @@ export async function saveImageToMediaLibrary({ url.origin === 'https://cdn.bsky.app' && url.pathname.startsWith('/img/download/') ) { - downloadUrl(downloadUri, `witchsky-image.${formatToFileExt(format)}`, true) + downloadUrl( + downloadUri, + getMediaDownloadFilename({ + downloadName, + uri, + extension: formatToFileExt(format), + kind: 'image', + }), + true, + ) return } const response = await fetch(downloadUri) @@ -73,13 +85,22 @@ export async function saveImageToMediaLibrary({ (format === 'original' ? 'bin' : formatToFileExt(format)) const localUrl = URL.createObjectURL(blob) try { - downloadUrl(localUrl, `witchsky-image.${extension}`) + downloadUrl( + localUrl, + getMediaDownloadFilename({downloadName, uri, extension, kind: 'image'}), + ) } finally { setTimeout(() => URL.revokeObjectURL(localUrl), 1000) } } -export async function downloadVideoWeb({uri}: {uri: string}) { +export async function downloadVideoWeb({ + uri, + downloadName, +}: { + uri: string + downloadName?: string +}) { // download the file to cache const downloadResponse = await fetch(uri) .then(res => res.blob()) @@ -88,10 +109,14 @@ export async function downloadVideoWeb({uri}: {uri: string}) { const extension = mimeToExt(downloadResponse.type) const blobUrl = URL.createObjectURL(downloadResponse) - const link = document.createElement('a') - link.setAttribute('download', uri.slice(-10) + '.' + extension) - link.setAttribute('href', blobUrl) - link.click() + try { + downloadUrl( + blobUrl, + getMediaDownloadFilename({downloadName, uri, extension, kind: 'video'}), + ) + } finally { + setTimeout(() => URL.revokeObjectURL(blobUrl), 1000) + } return true } diff --git a/src/lib/media/save-image.ios.ts b/src/lib/media/save-image.ios.ts index 6180eb5a9..e89cfe266 100644 --- a/src/lib/media/save-image.ios.ts +++ b/src/lib/media/save-image.ios.ts @@ -17,7 +17,7 @@ export function useSaveImageToMediaLibrary() { const {_} = useLingui() const downloadFormat = useDownloadFormat() return useCallback( - async (uri: string, format?: string) => { + async (uri: string, format?: string, downloadName?: string) => { if (!IS_NATIVE) { throw new Error('useSaveImageToMediaLibrary is native only') } @@ -25,6 +25,7 @@ export function useSaveImageToMediaLibrary() { try { await saveImageToMediaLibrary({ uri, + downloadName, format: format ?? downloadFormat ?? 'original', }) Toast.show(_(msg`Image saved`)) diff --git a/src/lib/media/save-image.ts b/src/lib/media/save-image.ts index d18d2eab7..a03e30f5e 100644 --- a/src/lib/media/save-image.ts +++ b/src/lib/media/save-image.ts @@ -19,7 +19,7 @@ export function useSaveImageToMediaLibrary() { granularPermissions: ['photo'], }) return useCallback( - async (uri: string, format?: string) => { + async (uri: string, format?: string, downloadName?: string) => { if (!IS_NATIVE) { throw new Error('useSaveImageToMediaLibrary is native only') } @@ -28,6 +28,7 @@ export function useSaveImageToMediaLibrary() { try { await saveImageToMediaLibrary({ uri, + downloadName, format: format ?? downloadFormat ?? 'original', }) diff --git a/src/lib/media/saveVideoToDevice.ts b/src/lib/media/saveVideoToDevice.ts index cac529d5f..33e59be63 100644 --- a/src/lib/media/saveVideoToDevice.ts +++ b/src/lib/media/saveVideoToDevice.ts @@ -1,5 +1,11 @@ import {saveVideoToMediaLibrary} from './manip' -export async function saveVideoToDevice({uri}: {uri: string}) { - return await saveVideoToMediaLibrary({uri}) +export async function saveVideoToDevice({ + uri, + downloadName, +}: { + uri: string + downloadName?: string +}) { + return await saveVideoToMediaLibrary({uri, downloadName}) } diff --git a/src/lib/media/saveVideoToDevice.web.ts b/src/lib/media/saveVideoToDevice.web.ts index 6a949d686..78f366537 100644 --- a/src/lib/media/saveVideoToDevice.web.ts +++ b/src/lib/media/saveVideoToDevice.web.ts @@ -1,5 +1,11 @@ import {downloadVideoWeb} from './manip.web' -export async function saveVideoToDevice({uri}: {uri: string}) { - return await downloadVideoWeb({uri}) +export async function saveVideoToDevice({ + uri, + downloadName, +}: { + uri: string + downloadName?: string +}) { + return await downloadVideoWeb({uri, downloadName}) } diff --git a/src/screens/Profile/Header/Shell.tsx b/src/screens/Profile/Header/Shell.tsx index 44898cd2b..ce7e6c6c6 100644 --- a/src/screens/Profile/Header/Shell.tsx +++ b/src/screens/Profile/Header/Shell.tsx @@ -95,6 +95,7 @@ let ProfileHeaderShell = ({ openLightbox({ images: [ { + downloadName: `${profile.handle}-${type === 'image' ? 'banner' : 'avatar'}`, uri: applyImageTransforms(uri, { imageCdnHost, format: fullsizeFormat, @@ -131,6 +132,7 @@ let ProfileHeaderShell = ({ }, [ openLightbox, + profile.handle, imageCdnHost, fullsizeFormat, thumbnailFormat, diff --git a/src/view/com/notifications/NotificationFeedItem.tsx b/src/view/com/notifications/NotificationFeedItem.tsx index f53de68df..8d490ecf3 100644 --- a/src/view/com/notifications/NotificationFeedItem.tsx +++ b/src/view/com/notifications/NotificationFeedItem.tsx @@ -1461,6 +1461,7 @@ function AdditionalPostText({post}: {post?: app.bsky.feed.defs.PostView}) { embed={post.embed} style={styles.additionalPostImages} peekable + post={post} /> ) -- knotmirror 0.0.1