Add “download image” option to web, simplify downloads (#10025)

This commit is contained in:
Samuel Newman
2026-03-11 23:44:19 +02:00
committed by GitHub
parent 0c7b2d5353
commit 80429ec902
5 changed files with 108 additions and 18 deletions
+14 -7
View File
@@ -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=<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 {
+17 -8
View File
@@ -1,8 +1,6 @@
/// <reference lib="dom" />
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=<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<Dimensions> {
@@ -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() {
+19
View File
@@ -26,3 +26,22 @@ export function blobToDataUri(blob: Blob): Promise<string> {
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)
}
+56
View File
@@ -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({
<Text>{_(msg`Image ${index + 1} of ${imgs.length}`)}</Text>
</div>
)}
<Menu.Root>
<Menu.Trigger label={_(msg`Image options`)}>
{({props}) => (
<Button
{...props}
style={[
a.absolute,
styles.menuBtn,
styles.blurredBackdrop,
a.transition_color,
delayedFadeInAnim,
]}
hoverStyle={styles.blurredBackdropHover}
color="secondary"
label={_(msg`Image options`)}
shape="round"
size={gtPhone ? 'large' : 'small'}>
<EllipsisIcon
size={gtPhone ? 'md' : 'sm'}
style={{color: t.palette.white}}
/>
</Button>
)}
</Menu.Trigger>
<Menu.Outer>
<Menu.Group>
<Menu.Item
label={_(msg`Download image`)}
onPress={() => {
saveImageToMediaLibrary({uri: img.uri}).then(
() => {
Toast.show(_(msg`Image saved`))
},
() => {
Toast.show(_(msg`Failed to save image`), {type: 'error'})
},
)
}}>
<Menu.ItemText>
<Trans>Download image</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={DownloadIcon} position="right" />
</Menu.Item>
</Menu.Group>
</Menu.Outer>
</Menu.Root>
<Button
onPress={onClose}
style={[
@@ -369,6 +421,10 @@ const styles = StyleSheet.create({
padding: 16,
boxSizing: 'border-box',
},
menuBtn: {
top: 20,
left: 20,
},
closeBtn: {
top: 20,
right: 20,
+2 -3
View File
@@ -24,6 +24,7 @@ import {
import {compressIfNeeded} from '#/lib/media/manip'
import {openCamera, openCropper, openPicker} from '#/lib/media/picker'
import {type PickerImage} from '#/lib/media/picker.shared'
import {convertCdnPreset} from '#/lib/media/util'
import {makeProfileLink} from '#/lib/routes/links'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {isCancelledError} from '#/lib/strings/errors'
@@ -616,9 +617,7 @@ export {PreviewableUserAvatar}
// manually string-replace to use the smaller ones
// -prf
function hackModifyThumbnailPath(uri: string, isEnabled: boolean): string {
return isEnabled
? uri.replace('/img/avatar/plain/', '/img/avatar_thumbnail/plain/')
: uri
return isEnabled ? convertCdnPreset(uri, 'avatar_thumbnail') : uri
}
const styles = StyleSheet.create({