Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

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

authored by

Samuel Newman and committed by
GitHub
80429ec9 0c7b2d53

+108 -18
+14 -7
src/lib/media/manip.ts
··· 22 22 import {IS_ANDROID, IS_IOS} from '#/env' 23 23 import {type PickerImage} from './picker.shared' 24 24 import {type Dimensions} from './types' 25 + import {convertCdnPreset} from './util' 25 26 26 27 export async function compressIfNeeded( 27 28 img: PickerImage, ··· 94 95 95 96 const ALBUM_NAME = 'Bluesky' 96 97 98 + /** 99 + * Saves an image to the user's device. Uses the CDN's `download` preset 100 + * which uses the JPEG version with the Content-Disposition header set to 101 + * `attachment; filename=<filename>`. On native this saves to the media library; 102 + * on web it triggers a browser download. 103 + */ 97 104 export async function saveImageToMediaLibrary({uri}: {uri: string}) { 98 - const downloadedPath = await downloadImage(uri, String(uuid.v4()), 15e3) 99 - const {uri: jpegUri} = await manipulateAsync(downloadedPath, [], { 100 - format: SaveFormat.JPEG, 101 - compress: 1.0, 102 - }) 103 - void safeDeleteAsync(downloadedPath) 104 - const imagePath = await moveToPermanentPath(jpegUri, '.jpg') 105 + const downloadUri = convertCdnPreset(uri, 'download') 106 + const downloadedPath = await downloadImage( 107 + downloadUri, 108 + String(uuid.v4()), 109 + 20e3, 110 + ) 111 + const imagePath = await moveToPermanentPath(downloadedPath, '.jpg') 105 112 106 113 // save 107 114 try {
+17 -8
src/lib/media/manip.web.ts
··· 1 - /// <reference lib="dom" /> 2 - 3 1 import {type PickerImage} from './picker.shared' 4 2 import {type Dimensions} from './types' 5 - import {blobToDataUri, getDataUriSize} from './util' 3 + import {blobToDataUri, convertCdnPreset, getDataUriSize} from './util' 6 4 7 5 export async function compressIfNeeded( 8 6 img: PickerImage, ··· 44 42 throw new Error('TODO') 45 43 } 46 44 47 - export async function saveImageToMediaLibrary(_opts: {uri: string}) { 48 - // TODO 49 - throw new Error('TODO') 45 + /** 46 + * Saves an image to the user's device. Uses the CDN's `download` preset 47 + * which uses the JPEG version with the Content-Disposition header set to 48 + * `attachment; filename=<filename>`. On native this saves to the media library; 49 + * on web it triggers a browser download. 50 + */ 51 + export async function saveImageToMediaLibrary({uri}: {uri: string}) { 52 + const downloadUri = convertCdnPreset(uri, 'download') 53 + const segments = downloadUri.split('/') 54 + const filename = `bluesky-${segments.at(-1)}.jpg` 55 + downloadUrl(downloadUri, filename) 50 56 } 51 57 52 58 export async function getImageDim(path: string): Promise<Dimensions> { ··· 162 168 ) { 163 169 const blob = new Blob([bytes], {type}) 164 170 const url = URL.createObjectURL(blob) 165 - await downloadUrl(url, filename) 171 + downloadUrl(url, filename) 166 172 // Firefox requires a small delay 167 173 setTimeout(() => URL.revokeObjectURL(url), 100) 168 174 return true 169 175 } 170 176 171 - async function downloadUrl(href: string, filename: string) { 177 + function downloadUrl(href: string, filename: string) { 172 178 const a = document.createElement('a') 173 179 a.href = href 174 180 a.download = filename 181 + a.style.display = 'none' 182 + document.body.appendChild(a) 175 183 a.click() 184 + document.body.removeChild(a) 176 185 } 177 186 178 187 export async function safeDeleteAsync() {
+19
src/lib/media/util.ts
··· 26 26 reader.readAsDataURL(blob) 27 27 }) 28 28 } 29 + 30 + export type ImgproxyPreset = 31 + | 'default' 32 + | 'avatar_thumbnail' 33 + | 'avatar' 34 + | 'banner' 35 + | 'feed_fullsize' 36 + | 'feed_thumbnail' 37 + | 'download' 38 + 39 + const IMGPROXY_PRESET_RE = 40 + /(?<=\/img\/)(default|avatar_thumbnail|avatar|banner|feed_fullsize|feed_thumbnail|download)(?=\/)/ 41 + 42 + /** 43 + * Replaces any imgproxy preset in a CDN URI with the given preset. 44 + */ 45 + export function convertCdnPreset(uri: string, preset: ImgproxyPreset): string { 46 + return uri.replace(IMGPROXY_PRESET_RE, preset) 47 + }
+56
src/view/com/lightbox/Lightbox.web.tsx
··· 3 3 import {Image} from 'expo-image' 4 4 import {msg} from '@lingui/core/macro' 5 5 import {useLingui} from '@lingui/react' 6 + import {Trans} from '@lingui/react/macro' 6 7 import {FocusGuards, FocusScope} from 'radix-ui/internal' 7 8 import {RemoveScrollBar} from 'react-remove-scroll-bar' 8 9 10 + import {saveImageToMediaLibrary} from '#/lib/media/manip' 9 11 import {useA11y} from '#/state/a11y' 10 12 import {useLightbox, useLightboxControls} from '#/state/lightbox' 11 13 import { ··· 21 23 ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeftIcon, 22 24 ChevronRight_Stroke2_Corner0_Rounded as ChevronRightIcon, 23 25 } from '#/components/icons/Chevron' 26 + import {DotGrid3x1_Stroke2_Corner0_Rounded as EllipsisIcon} from '#/components/icons/DotGrid' 27 + import {Download_Stroke2_Corner0_Rounded as DownloadIcon} from '#/components/icons/Download' 24 28 import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' 25 29 import {Loader} from '#/components/Loader' 30 + import * as Menu from '#/components/Menu' 31 + import * as Toast from '#/components/Toast' 26 32 import {Text} from '#/components/Typography' 27 33 import {type ImageSource} from './ImageViewing/@types' 28 34 ··· 242 248 <Text>{_(msg`Image ${index + 1} of ${imgs.length}`)}</Text> 243 249 </div> 244 250 )} 251 + <Menu.Root> 252 + <Menu.Trigger label={_(msg`Image options`)}> 253 + {({props}) => ( 254 + <Button 255 + {...props} 256 + style={[ 257 + a.absolute, 258 + styles.menuBtn, 259 + styles.blurredBackdrop, 260 + a.transition_color, 261 + delayedFadeInAnim, 262 + ]} 263 + hoverStyle={styles.blurredBackdropHover} 264 + color="secondary" 265 + label={_(msg`Image options`)} 266 + shape="round" 267 + size={gtPhone ? 'large' : 'small'}> 268 + <EllipsisIcon 269 + size={gtPhone ? 'md' : 'sm'} 270 + style={{color: t.palette.white}} 271 + /> 272 + </Button> 273 + )} 274 + </Menu.Trigger> 275 + <Menu.Outer> 276 + <Menu.Group> 277 + <Menu.Item 278 + label={_(msg`Download image`)} 279 + onPress={() => { 280 + saveImageToMediaLibrary({uri: img.uri}).then( 281 + () => { 282 + Toast.show(_(msg`Image saved`)) 283 + }, 284 + () => { 285 + Toast.show(_(msg`Failed to save image`), {type: 'error'}) 286 + }, 287 + ) 288 + }}> 289 + <Menu.ItemText> 290 + <Trans>Download image</Trans> 291 + </Menu.ItemText> 292 + <Menu.ItemIcon icon={DownloadIcon} position="right" /> 293 + </Menu.Item> 294 + </Menu.Group> 295 + </Menu.Outer> 296 + </Menu.Root> 245 297 <Button 246 298 onPress={onClose} 247 299 style={[ ··· 368 420 maxHeight: `calc(min(400px, 100vh))`, 369 421 padding: 16, 370 422 boxSizing: 'border-box', 423 + }, 424 + menuBtn: { 425 + top: 20, 426 + left: 20, 371 427 }, 372 428 closeBtn: { 373 429 top: 20,
+2 -3
src/view/com/util/UserAvatar.tsx
··· 24 24 import {compressIfNeeded} from '#/lib/media/manip' 25 25 import {openCamera, openCropper, openPicker} from '#/lib/media/picker' 26 26 import {type PickerImage} from '#/lib/media/picker.shared' 27 + import {convertCdnPreset} from '#/lib/media/util' 27 28 import {makeProfileLink} from '#/lib/routes/links' 28 29 import {sanitizeDisplayName} from '#/lib/strings/display-names' 29 30 import {isCancelledError} from '#/lib/strings/errors' ··· 616 617 // manually string-replace to use the smaller ones 617 618 // -prf 618 619 function hackModifyThumbnailPath(uri: string, isEnabled: boolean): string { 619 - return isEnabled 620 - ? uri.replace('/img/avatar/plain/', '/img/avatar_thumbnail/plain/') 621 - : uri 620 + return isEnabled ? convertCdnPreset(uri, 'avatar_thumbnail') : uri 622 621 } 623 622 624 623 const styles = StyleSheet.create({