Add “download image” option to web, simplify downloads (#10025)
This commit is contained in:
+14
-7
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user