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 {IS_ANDROID, IS_IOS} from '#/env'
|
||||||
import {type PickerImage} from './picker.shared'
|
import {type PickerImage} from './picker.shared'
|
||||||
import {type Dimensions} from './types'
|
import {type Dimensions} from './types'
|
||||||
|
import {convertCdnPreset} from './util'
|
||||||
|
|
||||||
export async function compressIfNeeded(
|
export async function compressIfNeeded(
|
||||||
img: PickerImage,
|
img: PickerImage,
|
||||||
@@ -94,14 +95,20 @@ export async function shareImageModal({uri}: {uri: string}) {
|
|||||||
|
|
||||||
const ALBUM_NAME = 'Bluesky'
|
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}) {
|
export async function saveImageToMediaLibrary({uri}: {uri: string}) {
|
||||||
const downloadedPath = await downloadImage(uri, String(uuid.v4()), 15e3)
|
const downloadUri = convertCdnPreset(uri, 'download')
|
||||||
const {uri: jpegUri} = await manipulateAsync(downloadedPath, [], {
|
const downloadedPath = await downloadImage(
|
||||||
format: SaveFormat.JPEG,
|
downloadUri,
|
||||||
compress: 1.0,
|
String(uuid.v4()),
|
||||||
})
|
20e3,
|
||||||
void safeDeleteAsync(downloadedPath)
|
)
|
||||||
const imagePath = await moveToPermanentPath(jpegUri, '.jpg')
|
const imagePath = await moveToPermanentPath(downloadedPath, '.jpg')
|
||||||
|
|
||||||
// save
|
// save
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
/// <reference lib="dom" />
|
|
||||||
|
|
||||||
import {type PickerImage} from './picker.shared'
|
import {type PickerImage} from './picker.shared'
|
||||||
import {type Dimensions} from './types'
|
import {type Dimensions} from './types'
|
||||||
import {blobToDataUri, getDataUriSize} from './util'
|
import {blobToDataUri, convertCdnPreset, getDataUriSize} from './util'
|
||||||
|
|
||||||
export async function compressIfNeeded(
|
export async function compressIfNeeded(
|
||||||
img: PickerImage,
|
img: PickerImage,
|
||||||
@@ -44,9 +42,17 @@ export async function shareImageModal(_opts: {uri: string}) {
|
|||||||
throw new Error('TODO')
|
throw new Error('TODO')
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveImageToMediaLibrary(_opts: {uri: string}) {
|
/**
|
||||||
// TODO
|
* Saves an image to the user's device. Uses the CDN's `download` preset
|
||||||
throw new Error('TODO')
|
* 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> {
|
export async function getImageDim(path: string): Promise<Dimensions> {
|
||||||
@@ -162,17 +168,20 @@ export async function saveBytesToDisk(
|
|||||||
) {
|
) {
|
||||||
const blob = new Blob([bytes], {type})
|
const blob = new Blob([bytes], {type})
|
||||||
const url = URL.createObjectURL(blob)
|
const url = URL.createObjectURL(blob)
|
||||||
await downloadUrl(url, filename)
|
downloadUrl(url, filename)
|
||||||
// Firefox requires a small delay
|
// Firefox requires a small delay
|
||||||
setTimeout(() => URL.revokeObjectURL(url), 100)
|
setTimeout(() => URL.revokeObjectURL(url), 100)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
async function downloadUrl(href: string, filename: string) {
|
function downloadUrl(href: string, filename: string) {
|
||||||
const a = document.createElement('a')
|
const a = document.createElement('a')
|
||||||
a.href = href
|
a.href = href
|
||||||
a.download = filename
|
a.download = filename
|
||||||
|
a.style.display = 'none'
|
||||||
|
document.body.appendChild(a)
|
||||||
a.click()
|
a.click()
|
||||||
|
document.body.removeChild(a)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function safeDeleteAsync() {
|
export async function safeDeleteAsync() {
|
||||||
|
|||||||
@@ -26,3 +26,22 @@ export function blobToDataUri(blob: Blob): Promise<string> {
|
|||||||
reader.readAsDataURL(blob)
|
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 {Image} from 'expo-image'
|
||||||
import {msg} from '@lingui/core/macro'
|
import {msg} from '@lingui/core/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
|
import {Trans} from '@lingui/react/macro'
|
||||||
import {FocusGuards, FocusScope} from 'radix-ui/internal'
|
import {FocusGuards, FocusScope} from 'radix-ui/internal'
|
||||||
import {RemoveScrollBar} from 'react-remove-scroll-bar'
|
import {RemoveScrollBar} from 'react-remove-scroll-bar'
|
||||||
|
|
||||||
|
import {saveImageToMediaLibrary} from '#/lib/media/manip'
|
||||||
import {useA11y} from '#/state/a11y'
|
import {useA11y} from '#/state/a11y'
|
||||||
import {useLightbox, useLightboxControls} from '#/state/lightbox'
|
import {useLightbox, useLightboxControls} from '#/state/lightbox'
|
||||||
import {
|
import {
|
||||||
@@ -21,8 +23,12 @@ import {
|
|||||||
ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeftIcon,
|
ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeftIcon,
|
||||||
ChevronRight_Stroke2_Corner0_Rounded as ChevronRightIcon,
|
ChevronRight_Stroke2_Corner0_Rounded as ChevronRightIcon,
|
||||||
} from '#/components/icons/Chevron'
|
} 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 {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
|
||||||
import {Loader} from '#/components/Loader'
|
import {Loader} from '#/components/Loader'
|
||||||
|
import * as Menu from '#/components/Menu'
|
||||||
|
import * as Toast from '#/components/Toast'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
import {type ImageSource} from './ImageViewing/@types'
|
import {type ImageSource} from './ImageViewing/@types'
|
||||||
|
|
||||||
@@ -242,6 +248,52 @@ function LightboxGallery({
|
|||||||
<Text>{_(msg`Image ${index + 1} of ${imgs.length}`)}</Text>
|
<Text>{_(msg`Image ${index + 1} of ${imgs.length}`)}</Text>
|
||||||
</div>
|
</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
|
<Button
|
||||||
onPress={onClose}
|
onPress={onClose}
|
||||||
style={[
|
style={[
|
||||||
@@ -369,6 +421,10 @@ const styles = StyleSheet.create({
|
|||||||
padding: 16,
|
padding: 16,
|
||||||
boxSizing: 'border-box',
|
boxSizing: 'border-box',
|
||||||
},
|
},
|
||||||
|
menuBtn: {
|
||||||
|
top: 20,
|
||||||
|
left: 20,
|
||||||
|
},
|
||||||
closeBtn: {
|
closeBtn: {
|
||||||
top: 20,
|
top: 20,
|
||||||
right: 20,
|
right: 20,
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
import {compressIfNeeded} from '#/lib/media/manip'
|
import {compressIfNeeded} from '#/lib/media/manip'
|
||||||
import {openCamera, openCropper, openPicker} from '#/lib/media/picker'
|
import {openCamera, openCropper, openPicker} from '#/lib/media/picker'
|
||||||
import {type PickerImage} from '#/lib/media/picker.shared'
|
import {type PickerImage} from '#/lib/media/picker.shared'
|
||||||
|
import {convertCdnPreset} from '#/lib/media/util'
|
||||||
import {makeProfileLink} from '#/lib/routes/links'
|
import {makeProfileLink} from '#/lib/routes/links'
|
||||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||||
import {isCancelledError} from '#/lib/strings/errors'
|
import {isCancelledError} from '#/lib/strings/errors'
|
||||||
@@ -616,9 +617,7 @@ export {PreviewableUserAvatar}
|
|||||||
// manually string-replace to use the smaller ones
|
// manually string-replace to use the smaller ones
|
||||||
// -prf
|
// -prf
|
||||||
function hackModifyThumbnailPath(uri: string, isEnabled: boolean): string {
|
function hackModifyThumbnailPath(uri: string, isEnabled: boolean): string {
|
||||||
return isEnabled
|
return isEnabled ? convertCdnPreset(uri, 'avatar_thumbnail') : uri
|
||||||
? uri.replace('/img/avatar/plain/', '/img/avatar_thumbnail/plain/')
|
|
||||||
: uri
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
|
|||||||
Reference in New Issue
Block a user