From 80429ec9025eff6222f73bcf59b99b1b2bc553ce Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Wed, 11 Mar 2026 23:44:19 +0200 Subject: [PATCH] =?UTF-8?q?Add=20=E2=80=9Cdownload=20image=E2=80=9D=20opti?= =?UTF-8?q?on=20to=20web,=20simplify=20downloads=20(#10025)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/media/manip.ts | 21 ++++++---- src/lib/media/manip.web.ts | 25 ++++++++---- src/lib/media/util.ts | 19 +++++++++ src/view/com/lightbox/Lightbox.web.tsx | 56 ++++++++++++++++++++++++++ src/view/com/util/UserAvatar.tsx | 5 +-- 5 files changed, 108 insertions(+), 18 deletions(-) diff --git a/src/lib/media/manip.ts b/src/lib/media/manip.ts index 0cc92f446d..6d66fea489 100644 --- a/src/lib/media/manip.ts +++ b/src/lib/media/manip.ts @@ -22,6 +22,7 @@ import {logger} from '#/logger' import {IS_ANDROID, IS_IOS} from '#/env' import {type PickerImage} from './picker.shared' import {type Dimensions} from './types' +import {convertCdnPreset} from './util' export async function compressIfNeeded( img: PickerImage, @@ -94,14 +95,20 @@ export async function shareImageModal({uri}: {uri: string}) { const ALBUM_NAME = 'Bluesky' +/** + * Saves an image to the user's device. Uses the CDN's `download` preset + * which uses the JPEG version with the Content-Disposition header set to + * `attachment; filename=`. On native this saves to the media library; + * on web it triggers a browser download. + */ export async function saveImageToMediaLibrary({uri}: {uri: string}) { - const downloadedPath = await downloadImage(uri, String(uuid.v4()), 15e3) - const {uri: jpegUri} = await manipulateAsync(downloadedPath, [], { - format: SaveFormat.JPEG, - compress: 1.0, - }) - void safeDeleteAsync(downloadedPath) - const imagePath = await moveToPermanentPath(jpegUri, '.jpg') + const downloadUri = convertCdnPreset(uri, 'download') + const downloadedPath = await downloadImage( + downloadUri, + String(uuid.v4()), + 20e3, + ) + const imagePath = await moveToPermanentPath(downloadedPath, '.jpg') // save try { diff --git a/src/lib/media/manip.web.ts b/src/lib/media/manip.web.ts index 1f6d90ce34..8fa2b6a4fa 100644 --- a/src/lib/media/manip.web.ts +++ b/src/lib/media/manip.web.ts @@ -1,8 +1,6 @@ -/// - import {type PickerImage} from './picker.shared' import {type Dimensions} from './types' -import {blobToDataUri, getDataUriSize} from './util' +import {blobToDataUri, convertCdnPreset, getDataUriSize} from './util' export async function compressIfNeeded( img: PickerImage, @@ -44,9 +42,17 @@ export async function shareImageModal(_opts: {uri: string}) { throw new Error('TODO') } -export async function saveImageToMediaLibrary(_opts: {uri: string}) { - // TODO - throw new Error('TODO') +/** + * Saves an image to the user's device. Uses the CDN's `download` preset + * which uses the JPEG version with the Content-Disposition header set to + * `attachment; filename=`. On native this saves to the media library; + * on web it triggers a browser download. + */ +export async function saveImageToMediaLibrary({uri}: {uri: string}) { + const downloadUri = convertCdnPreset(uri, 'download') + const segments = downloadUri.split('/') + const filename = `bluesky-${segments.at(-1)}.jpg` + downloadUrl(downloadUri, filename) } export async function getImageDim(path: string): Promise { @@ -162,17 +168,20 @@ export async function saveBytesToDisk( ) { const blob = new Blob([bytes], {type}) const url = URL.createObjectURL(blob) - await downloadUrl(url, filename) + downloadUrl(url, filename) // Firefox requires a small delay setTimeout(() => URL.revokeObjectURL(url), 100) return true } -async function downloadUrl(href: string, filename: string) { +function downloadUrl(href: string, filename: string) { const a = document.createElement('a') a.href = href a.download = filename + a.style.display = 'none' + document.body.appendChild(a) a.click() + document.body.removeChild(a) } export async function safeDeleteAsync() { diff --git a/src/lib/media/util.ts b/src/lib/media/util.ts index 36b342204a..105c49066d 100644 --- a/src/lib/media/util.ts +++ b/src/lib/media/util.ts @@ -26,3 +26,22 @@ export function blobToDataUri(blob: Blob): Promise { reader.readAsDataURL(blob) }) } + +export type ImgproxyPreset = + | 'default' + | 'avatar_thumbnail' + | 'avatar' + | 'banner' + | 'feed_fullsize' + | 'feed_thumbnail' + | 'download' + +const IMGPROXY_PRESET_RE = + /(?<=\/img\/)(default|avatar_thumbnail|avatar|banner|feed_fullsize|feed_thumbnail|download)(?=\/)/ + +/** + * Replaces any imgproxy preset in a CDN URI with the given preset. + */ +export function convertCdnPreset(uri: string, preset: ImgproxyPreset): string { + return uri.replace(IMGPROXY_PRESET_RE, preset) +} diff --git a/src/view/com/lightbox/Lightbox.web.tsx b/src/view/com/lightbox/Lightbox.web.tsx index c017f2423d..ddfc4fd8b7 100644 --- a/src/view/com/lightbox/Lightbox.web.tsx +++ b/src/view/com/lightbox/Lightbox.web.tsx @@ -3,9 +3,11 @@ import {Pressable, StyleSheet, View} from 'react-native' import {Image} from 'expo-image' import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' +import {Trans} from '@lingui/react/macro' import {FocusGuards, FocusScope} from 'radix-ui/internal' import {RemoveScrollBar} from 'react-remove-scroll-bar' +import {saveImageToMediaLibrary} from '#/lib/media/manip' import {useA11y} from '#/state/a11y' import {useLightbox, useLightboxControls} from '#/state/lightbox' import { @@ -21,8 +23,12 @@ import { ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeftIcon, ChevronRight_Stroke2_Corner0_Rounded as ChevronRightIcon, } from '#/components/icons/Chevron' +import {DotGrid3x1_Stroke2_Corner0_Rounded as EllipsisIcon} from '#/components/icons/DotGrid' +import {Download_Stroke2_Corner0_Rounded as DownloadIcon} from '#/components/icons/Download' import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' import {Loader} from '#/components/Loader' +import * as Menu from '#/components/Menu' +import * as Toast from '#/components/Toast' import {Text} from '#/components/Typography' import {type ImageSource} from './ImageViewing/@types' @@ -242,6 +248,52 @@ function LightboxGallery({ {_(msg`Image ${index + 1} of ${imgs.length}`)} )} + + + {({props}) => ( + + )} + + + + { + saveImageToMediaLibrary({uri: img.uri}).then( + () => { + Toast.show(_(msg`Image saved`)) + }, + () => { + Toast.show(_(msg`Failed to save image`), {type: 'error'}) + }, + ) + }}> + + Download image + + + + + +