mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
0
fork

Configure Feed

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

Split image cropping into secondary step (#473)

* Split image cropping into secondary step

* Use ImageModel and GalleryModel

* Add fix for pasting image URLs

* Move models to state folder

* Fix things that broke after rebase

* Latest -- has image display bug

* Remove contentFit

* Fix iOS display in gallery

* Tuneup the api signatures and implement compress/resize on web

* Fix await

* Lint fix and remove unused function

* Fix android image pathing

* Fix external embed x button on android

* Remove min-height from composer (no longer useful and was mispositioning the composer on android)

* Fix e2e picker

---------

Co-authored-by: Paul Frazee <pfrazee@gmail.com>

authored by

Ollie Hsieh
Paul Frazee
and committed by
GitHub
2509290f 91fadadb

+859 -817
+3
.gitignore
··· 82 82 # Temporary files created by Metro to check the health of the file watcher 83 83 .metro-health-check* 84 84 85 + # VSCode 86 + .vscode 87 + 85 88 # gitignore and github actions 86 89 !.gitignore 87 90 !.github
+1 -1
src/lib/api/index.ts
··· 10 10 import {AtUri} from '@atproto/api' 11 11 import {RootStoreModel} from 'state/models/root-store' 12 12 import {isNetworkError} from 'lib/strings/errors' 13 + import {Image} from 'lib/media/types' 13 14 import {LinkMeta} from '../link-meta/link-meta' 14 - import {Image} from '../media/manip' 15 15 import {isWeb} from 'platform/detection' 16 16 17 17 export interface ExternalEmbedDraft {
+5 -3
src/lib/constants.ts
··· 161 161 } 162 162 } 163 163 164 - export const POST_IMG_MAX_WIDTH = 2000 165 - export const POST_IMG_MAX_HEIGHT = 2000 166 - export const POST_IMG_MAX_SIZE = 1000000 164 + export const POST_IMG_MAX = { 165 + width: 2000, 166 + height: 2000, 167 + size: 1000000, 168 + }
+115 -83
src/lib/media/manip.ts
··· 1 1 import RNFetchBlob from 'rn-fetch-blob' 2 2 import ImageResizer from '@bam.tech/react-native-image-resizer' 3 3 import {Image as RNImage, Share} from 'react-native' 4 + import {Image} from 'react-native-image-crop-picker' 4 5 import RNFS from 'react-native-fs' 5 6 import uuid from 'react-native-uuid' 6 7 import * as Toast from 'view/com/util/Toast' 8 + import {Dimensions} from './types' 9 + import {POST_IMG_MAX} from 'lib/constants' 10 + import {isAndroid} from 'platform/detection' 11 + 12 + export async function compressAndResizeImageForPost( 13 + image: Image, 14 + ): Promise<Image> { 15 + const uri = `file://${image.path}` 16 + let resized: Omit<Image, 'mime'> 17 + 18 + for (let i = 0; i < 9; i++) { 19 + const quality = 100 - i * 10 7 20 8 - export interface Dim { 9 - width: number 10 - height: number 21 + try { 22 + resized = await ImageResizer.createResizedImage( 23 + uri, 24 + POST_IMG_MAX.width, 25 + POST_IMG_MAX.height, 26 + 'JPEG', 27 + quality, 28 + undefined, 29 + undefined, 30 + undefined, 31 + {mode: 'cover'}, 32 + ) 33 + } catch (err) { 34 + throw new Error(`Failed to resize: ${err}`) 35 + } 36 + 37 + if (resized.size < POST_IMG_MAX.size) { 38 + const path = await moveToPermanentPath(resized.path) 39 + 40 + return { 41 + path, 42 + mime: 'image/jpeg', 43 + size: resized.size, 44 + height: resized.height, 45 + width: resized.width, 46 + } 47 + } 48 + } 49 + 50 + throw new Error( 51 + `This image is too big! We couldn't compress it down to ${POST_IMG_MAX.size} bytes`, 52 + ) 53 + } 54 + 55 + export async function compressIfNeeded( 56 + img: Image, 57 + maxSize: number = 1000000, 58 + ): Promise<Image> { 59 + const origUri = `file://${img.path}` 60 + if (img.size < maxSize) { 61 + return img 62 + } 63 + const resizedImage = await doResize(origUri, { 64 + width: img.width, 65 + height: img.height, 66 + mode: 'stretch', 67 + maxSize, 68 + }) 69 + const finalImageMovedPath = await moveToPermanentPath(resizedImage.path) 70 + const finalImg = { 71 + ...resizedImage, 72 + path: finalImageMovedPath, 73 + } 74 + return finalImg 11 75 } 12 76 13 77 export interface DownloadAndResizeOpts { ··· 19 83 timeout: number 20 84 } 21 85 22 - export interface Image { 23 - path: string 24 - mime: string 25 - size: number 26 - width: number 27 - height: number 28 - } 29 - 30 86 export async function downloadAndResize(opts: DownloadAndResizeOpts) { 31 87 let appendExt = 'jpeg' 32 88 try { ··· 55 111 localUri = `file://${localUri}` 56 112 } 57 113 58 - return await resize(localUri, opts) 114 + return await doResize(localUri, opts) 59 115 } finally { 60 116 if (downloadRes) { 61 117 downloadRes.flush() ··· 63 119 } 64 120 } 65 121 66 - export interface ResizeOpts { 122 + export async function saveImageModal({uri}: {uri: string}) { 123 + const downloadResponse = await RNFetchBlob.config({ 124 + fileCache: true, 125 + }).fetch('GET', uri) 126 + 127 + const imagePath = downloadResponse.path() 128 + const base64Data = await downloadResponse.readFile('base64') 129 + const result = await Share.share({ 130 + url: 'data:image/png;base64,' + base64Data, 131 + }) 132 + if (result.action === Share.sharedAction) { 133 + Toast.show('Image saved to gallery') 134 + } else if (result.action === Share.dismissedAction) { 135 + // dismissed 136 + } 137 + RNFS.unlink(imagePath) 138 + } 139 + 140 + export function getImageDim(path: string): Promise<Dimensions> { 141 + return new Promise((resolve, reject) => { 142 + RNImage.getSize( 143 + path, 144 + (width, height) => { 145 + resolve({width, height}) 146 + }, 147 + reject, 148 + ) 149 + }) 150 + } 151 + 152 + // internal methods 153 + // = 154 + 155 + interface DoResizeOpts { 67 156 width: number 68 157 height: number 69 158 mode: 'contain' | 'cover' | 'stretch' 70 159 maxSize: number 71 160 } 72 161 73 - export async function resize( 74 - localUri: string, 75 - opts: ResizeOpts, 76 - ): Promise<Image> { 162 + async function doResize(localUri: string, opts: DoResizeOpts): Promise<Image> { 77 163 for (let i = 0; i < 9; i++) { 78 164 const quality = 100 - i * 10 79 165 const resizeRes = await ImageResizer.createResizedImage( ··· 89 175 ) 90 176 if (resizeRes.size < opts.maxSize) { 91 177 return { 92 - path: resizeRes.path, 178 + path: normalizePath(resizeRes.path), 93 179 mime: 'image/jpeg', 94 180 size: resizeRes.size, 95 181 width: resizeRes.width, ··· 102 188 ) 103 189 } 104 190 105 - export async function compressIfNeeded( 106 - img: Image, 107 - maxSize: number, 108 - ): Promise<Image> { 109 - const origUri = `file://${img.path}` 110 - if (img.size < maxSize) { 111 - return img 112 - } 113 - const resizedImage = await resize(origUri, { 114 - width: img.width, 115 - height: img.height, 116 - mode: 'stretch', 117 - maxSize, 118 - }) 119 - const finalImageMovedPath = await moveToPremanantPath(resizedImage.path) 120 - const finalImg = { 121 - ...resizedImage, 122 - path: finalImageMovedPath, 123 - } 124 - return finalImg 125 - } 126 - 127 - export function scaleDownDimensions(dim: Dim, max: Dim): Dim { 128 - if (dim.width < max.width && dim.height < max.height) { 129 - return dim 130 - } 131 - let wScale = dim.width > max.width ? max.width / dim.width : 1 132 - let hScale = dim.height > max.height ? max.height / dim.height : 1 133 - if (wScale < hScale) { 134 - return {width: dim.width * wScale, height: dim.height * wScale} 135 - } 136 - return {width: dim.width * hScale, height: dim.height * hScale} 137 - } 138 - 139 - export async function saveImageModal({uri}: {uri: string}) { 140 - const downloadResponse = await RNFetchBlob.config({ 141 - fileCache: true, 142 - }).fetch('GET', uri) 143 - 144 - const imagePath = downloadResponse.path() 145 - const base64Data = await downloadResponse.readFile('base64') 146 - const result = await Share.share({ 147 - url: 'data:image/png;base64,' + base64Data, 148 - }) 149 - if (result.action === Share.sharedAction) { 150 - Toast.show('Image saved to gallery') 151 - } else if (result.action === Share.dismissedAction) { 152 - // dismissed 153 - } 154 - RNFS.unlink(imagePath) 155 - } 156 - 157 - export async function moveToPremanantPath(path: string) { 191 + async function moveToPermanentPath(path: string): Promise<string> { 158 192 /* 159 193 Since this package stores images in a temp directory, we need to move the file to a permanent location. 160 194 Relevant: IOS bug when trying to open a second time: 161 195 https://github.com/ivpusic/react-native-image-crop-picker/issues/1199 162 196 */ 163 197 const filename = uuid.v4() 198 + 164 199 const destinationPath = `${RNFS.TemporaryDirectoryPath}/${filename}` 165 - RNFS.moveFile(path, destinationPath) 166 - return destinationPath 200 + await RNFS.moveFile(path, destinationPath) 201 + return normalizePath(destinationPath) 167 202 } 168 203 169 - export function getImageDim(path: string): Promise<Dim> { 170 - return new Promise((resolve, reject) => { 171 - RNImage.getSize( 172 - path, 173 - (width, height) => { 174 - resolve({width, height}) 175 - }, 176 - reject, 177 - ) 178 - }) 204 + function normalizePath(str: string): string { 205 + if (isAndroid) { 206 + if (!str.startsWith('file://')) { 207 + return `file://${str}` 208 + } 209 + } 210 + return str 179 211 }
+114 -78
src/lib/media/manip.web.ts
··· 1 - // import {Share} from 'react-native' 2 - // import * as Toast from 'view/com/util/Toast' 3 - import {extractDataUriMime, getDataUriSize} from './util' 1 + import {Dimensions} from './types' 2 + import {Image as RNImage} from 'react-native-image-crop-picker' 3 + import {getDataUriSize, blobToDataUri} from './util' 4 + import {POST_IMG_MAX} from 'lib/constants' 5 + 6 + export async function compressAndResizeImageForPost({ 7 + path, 8 + width, 9 + height, 10 + }: { 11 + path: string 12 + width: number 13 + height: number 14 + }): Promise<RNImage> { 15 + // Compression is handled in `doResize` via `quality` 16 + return await doResize(path, { 17 + width, 18 + height, 19 + maxSize: POST_IMG_MAX.size, 20 + mode: 'stretch', 21 + }) 22 + } 23 + 24 + export async function compressIfNeeded( 25 + img: RNImage, 26 + maxSize: number, 27 + ): Promise<RNImage> { 28 + if (img.size < maxSize) { 29 + return img 30 + } 31 + return await doResize(img.path, { 32 + width: img.width, 33 + height: img.height, 34 + mode: 'stretch', 35 + maxSize, 36 + }) 37 + } 4 38 5 39 export interface DownloadAndResizeOpts { 6 40 uri: string ··· 9 43 mode: 'contain' | 'cover' | 'stretch' 10 44 maxSize: number 11 45 timeout: number 12 - } 13 - 14 - export interface Image { 15 - path: string 16 - mime: string 17 - size: number 18 - width: number 19 - height: number 20 46 } 21 47 22 48 export async function downloadAndResize(opts: DownloadAndResizeOpts) { ··· 27 53 clearTimeout(to) 28 54 29 55 const dataUri = await blobToDataUri(resBody) 30 - return await resize(dataUri, opts) 31 - } 32 - 33 - export interface ResizeOpts { 34 - width: number 35 - height: number 36 - mode: 'contain' | 'cover' | 'stretch' 37 - maxSize: number 38 - } 39 - 40 - export async function resize( 41 - dataUri: string, 42 - _opts: ResizeOpts, 43 - ): Promise<Image> { 44 - const dim = await getImageDim(dataUri) 45 - // TODO -- need to resize 46 - return { 47 - path: dataUri, 48 - mime: extractDataUriMime(dataUri), 49 - size: getDataUriSize(dataUri), 50 - width: dim.width, 51 - height: dim.height, 52 - } 53 - } 54 - 55 - export async function compressIfNeeded( 56 - img: Image, 57 - maxSize: number, 58 - ): Promise<Image> { 59 - if (img.size > maxSize) { 60 - // TODO 61 - throw new Error( 62 - "This image is too large and we haven't implemented compression yet -- sorry!", 63 - ) 64 - } 65 - return img 66 - } 67 - 68 - export interface Dim { 69 - width: number 70 - height: number 71 - } 72 - export function scaleDownDimensions(dim: Dim, max: Dim): Dim { 73 - if (dim.width < max.width && dim.height < max.height) { 74 - return dim 75 - } 76 - let wScale = dim.width > max.width ? max.width / dim.width : 1 77 - let hScale = dim.height > max.height ? max.height / dim.height : 1 78 - if (wScale < hScale) { 79 - return {width: dim.width * wScale, height: dim.height * wScale} 80 - } 81 - return {width: dim.width * hScale, height: dim.height * hScale} 56 + return await doResize(dataUri, opts) 82 57 } 83 58 84 59 export async function saveImageModal(_opts: {uri: string}) { ··· 86 61 throw new Error('TODO') 87 62 } 88 63 89 - export async function moveToPremanantPath(path: string) { 90 - return path 91 - } 92 - 93 - export async function getImageDim(path: string): Promise<Dim> { 64 + export async function getImageDim(path: string): Promise<Dimensions> { 94 65 var img = document.createElement('img') 95 66 const promise = new Promise((resolve, reject) => { 96 67 img.onload = resolve ··· 101 72 return {width: img.width, height: img.height} 102 73 } 103 74 104 - function blobToDataUri(blob: Blob): Promise<string> { 75 + // internal methods 76 + // = 77 + 78 + interface DoResizeOpts { 79 + width: number 80 + height: number 81 + mode: 'contain' | 'cover' | 'stretch' 82 + maxSize: number 83 + } 84 + 85 + async function doResize(dataUri: string, opts: DoResizeOpts): Promise<RNImage> { 86 + let newDataUri 87 + 88 + for (let i = 0; i <= 10; i++) { 89 + newDataUri = await createResizedImage(dataUri, { 90 + width: opts.width, 91 + height: opts.height, 92 + quality: 1 - i * 0.1, 93 + mode: opts.mode, 94 + }) 95 + if (getDataUriSize(newDataUri) < opts.maxSize) { 96 + break 97 + } 98 + } 99 + if (!newDataUri) { 100 + throw new Error('Failed to compress image') 101 + } 102 + return { 103 + path: newDataUri, 104 + mime: 'image/jpeg', 105 + size: getDataUriSize(newDataUri), 106 + width: opts.width, 107 + height: opts.height, 108 + } 109 + } 110 + 111 + function createResizedImage( 112 + dataUri: string, 113 + { 114 + width, 115 + height, 116 + quality, 117 + mode, 118 + }: { 119 + width: number 120 + height: number 121 + quality: number 122 + mode: 'contain' | 'cover' | 'stretch' 123 + }, 124 + ): Promise<string> { 105 125 return new Promise((resolve, reject) => { 106 - const reader = new FileReader() 107 - reader.onloadend = () => { 108 - if (typeof reader.result === 'string') { 109 - resolve(reader.result) 110 - } else { 111 - reject(new Error('Failed to read blob')) 126 + const img = document.createElement('img') 127 + img.addEventListener('load', () => { 128 + const canvas = document.createElement('canvas') 129 + const ctx = canvas.getContext('2d') 130 + if (!ctx) { 131 + return reject(new Error('Failed to resize image')) 112 132 } 113 - } 114 - reader.onerror = reject 115 - reader.readAsDataURL(blob) 133 + 134 + canvas.width = width 135 + canvas.height = height 136 + 137 + let scale = 1 138 + if (mode === 'cover') { 139 + scale = img.width < img.height ? width / img.width : height / img.height 140 + } else if (mode === 'contain') { 141 + scale = img.width > img.height ? width / img.width : height / img.height 142 + } 143 + let w = img.width * scale 144 + let h = img.height * scale 145 + let x = (width - w) / 2 146 + let y = (height - h) / 2 147 + 148 + ctx.drawImage(img, x, y, w, h) 149 + resolve(canvas.toDataURL('image/jpeg', quality)) 150 + }) 151 + img.src = dataUri 116 152 }) 117 153 }
+17 -89
src/lib/media/picker.e2e.tsx
··· 1 1 import {RootStoreModel} from 'state/index' 2 - import {PickerOpts, CameraOpts, CropperOpts, PickedMedia} from './types' 3 - import { 4 - scaleDownDimensions, 5 - Dim, 6 - compressIfNeeded, 7 - moveToPremanantPath, 8 - } from 'lib/media/manip' 9 - export type {PickedMedia} from './types' 2 + import {Image as RNImage} from 'react-native-image-crop-picker' 10 3 import RNFS from 'react-native-fs' 4 + import {CropperOptions} from './types' 5 + import {compressAndResizeImageForPost} from './manip' 11 6 12 7 let _imageCounter = 0 13 8 async function getFile() { ··· 17 12 .concat(['Media', 'DCIM', '100APPLE']) 18 13 .join('/'), 19 14 ) 20 - return files[_imageCounter++ % files.length] 21 - } 22 - 23 - export async function openPicker( 24 - _store: RootStoreModel, 25 - opts: PickerOpts, 26 - ): Promise<PickedMedia[]> { 27 - const mediaType = opts.mediaType || 'photo' 28 - const items = await getFile() 29 - const toMedia = (item: RNFS.ReadDirItem) => ({ 30 - mediaType, 31 - path: item.path, 15 + const file = files[_imageCounter++ % files.length] 16 + return await compressAndResizeImageForPost({ 17 + path: file.path, 32 18 mime: 'image/jpeg', 33 - size: item.size, 19 + size: file.size, 34 20 width: 4288, 35 21 height: 2848, 36 22 }) 37 - if (Array.isArray(items)) { 38 - return items.map(toMedia) 39 - } 40 - return [toMedia(items)] 41 23 } 42 24 43 - export async function openCamera( 44 - _store: RootStoreModel, 45 - opts: CameraOpts, 46 - ): Promise<PickedMedia> { 47 - const mediaType = opts.mediaType || 'photo' 48 - const item = await getFile() 49 - return { 50 - mediaType, 51 - path: item.path, 52 - mime: 'image/jpeg', 53 - size: item.size, 54 - width: 4288, 55 - height: 2848, 56 - } 25 + export async function openPicker(_store: RootStoreModel): Promise<RNImage[]> { 26 + return [await getFile()] 27 + } 28 + 29 + export async function openCamera(_store: RootStoreModel): Promise<RNImage> { 30 + return await getFile() 57 31 } 58 32 59 33 export async function openCropper( 60 34 _store: RootStoreModel, 61 - opts: CropperOpts, 62 - ): Promise<PickedMedia> { 63 - const mediaType = opts.mediaType || 'photo' 64 - const item = await getFile() 35 + opts: CropperOptions, 36 + ): Promise<RNImage> { 65 37 return { 66 - mediaType, 67 - path: item.path, 38 + path: opts.path, 68 39 mime: 'image/jpeg', 69 - size: item.size, 40 + size: 123, 70 41 width: 4288, 71 42 height: 2848, 72 43 } 73 44 } 74 - 75 - export async function pickImagesFlow( 76 - store: RootStoreModel, 77 - maxFiles: number, 78 - maxDim: Dim, 79 - maxSize: number, 80 - ) { 81 - const items = await openPicker(store, { 82 - multiple: true, 83 - maxFiles, 84 - mediaType: 'photo', 85 - }) 86 - const result = [] 87 - for (const image of items) { 88 - result.push( 89 - await cropAndCompressFlow(store, image.path, image, maxDim, maxSize), 90 - ) 91 - } 92 - return result 93 - } 94 - 95 - export async function cropAndCompressFlow( 96 - store: RootStoreModel, 97 - path: string, 98 - imgDim: Dim, 99 - maxDim: Dim, 100 - maxSize: number, 101 - ) { 102 - // choose target dimensions based on the original 103 - // this causes the photo cropper to start with the full image "selected" 104 - const {width, height} = scaleDownDimensions(imgDim, maxDim) 105 - const cropperRes = await openCropper(store, { 106 - mediaType: 'photo', 107 - path, 108 - freeStyleCropEnabled: true, 109 - width, 110 - height, 111 - }) 112 - 113 - const img = await compressIfNeeded(cropperRes, maxSize) 114 - const permanentPath = await moveToPremanantPath(img.path) 115 - return permanentPath 116 - }
+14 -73
src/lib/media/picker.tsx
··· 5 5 ImageOrVideo, 6 6 } from 'react-native-image-crop-picker' 7 7 import {RootStoreModel} from 'state/index' 8 - import {PickerOpts, CameraOpts, CropperOpts, PickedMedia} from './types' 9 - import { 10 - scaleDownDimensions, 11 - Dim, 12 - compressIfNeeded, 13 - moveToPremanantPath, 14 - } from 'lib/media/manip' 15 - export type {PickedMedia} from './types' 8 + import {PickerOpts, CameraOpts, CropperOptions} from './types' 9 + import {Image as RNImage} from 'react-native-image-crop-picker' 16 10 17 11 /** 18 12 * NOTE ··· 25 19 26 20 export async function openPicker( 27 21 _store: RootStoreModel, 28 - opts: PickerOpts, 29 - ): Promise<PickedMedia[]> { 30 - const mediaType = opts.mediaType || 'photo' 22 + opts?: PickerOpts, 23 + ): Promise<RNImage[]> { 31 24 const items = await openPickerFn({ 32 - mediaType, 33 - multiple: opts.multiple, 34 - maxFiles: opts.maxFiles, 25 + mediaType: 'photo', // TODO: eventually add other media types 26 + multiple: opts?.multiple, 27 + maxFiles: opts?.maxFiles, 35 28 forceJpg: true, // ios only 36 29 compressImageQuality: 0.8, 37 30 }) 31 + 38 32 const toMedia = (item: ImageOrVideo) => ({ 39 - mediaType, 40 33 path: item.path, 41 34 mime: item.mime, 42 35 size: item.size, ··· 52 45 export async function openCamera( 53 46 _store: RootStoreModel, 54 47 opts: CameraOpts, 55 - ): Promise<PickedMedia> { 56 - const mediaType = opts.mediaType || 'photo' 48 + ): Promise<RNImage> { 57 49 const item = await openCameraFn({ 58 - mediaType, 59 50 width: opts.width, 60 51 height: opts.height, 61 52 freeStyleCropEnabled: opts.freeStyleCropEnabled, 62 53 cropperCircleOverlay: opts.cropperCircleOverlay, 63 - cropping: true, 54 + cropping: false, 64 55 forceJpg: true, // ios only 65 56 compressImageQuality: 0.8, 66 57 }) 67 58 return { 68 - mediaType, 69 59 path: item.path, 70 60 mime: item.mime, 71 61 size: item.size, ··· 76 66 77 67 export async function openCropper( 78 68 _store: RootStoreModel, 79 - opts: CropperOpts, 80 - ): Promise<PickedMedia> { 81 - const mediaType = opts.mediaType || 'photo' 69 + opts: CropperOptions, 70 + ): Promise<RNImage> { 82 71 const item = await openCropperFn({ 83 - path: opts.path, 84 - mediaType: opts.mediaType || 'photo', 85 - width: opts.width, 86 - height: opts.height, 87 - freeStyleCropEnabled: opts.freeStyleCropEnabled, 88 - cropperCircleOverlay: opts.cropperCircleOverlay, 72 + ...opts, 89 73 forceJpg: true, // ios only 90 74 compressImageQuality: 0.8, 91 75 }) 76 + 92 77 return { 93 - mediaType, 94 78 path: item.path, 95 79 mime: item.mime, 96 80 size: item.size, ··· 98 82 height: item.height, 99 83 } 100 84 } 101 - 102 - export async function pickImagesFlow( 103 - store: RootStoreModel, 104 - maxFiles: number, 105 - maxDim: Dim, 106 - maxSize: number, 107 - ) { 108 - const items = await openPicker(store, { 109 - multiple: true, 110 - maxFiles, 111 - mediaType: 'photo', 112 - }) 113 - const result = [] 114 - for (const image of items) { 115 - result.push( 116 - await cropAndCompressFlow(store, image.path, image, maxDim, maxSize), 117 - ) 118 - } 119 - return result 120 - } 121 - 122 - export async function cropAndCompressFlow( 123 - store: RootStoreModel, 124 - path: string, 125 - imgDim: Dim, 126 - maxDim: Dim, 127 - maxSize: number, 128 - ) { 129 - // choose target dimensions based on the original 130 - // this causes the photo cropper to start with the full image "selected" 131 - const {width, height} = scaleDownDimensions(imgDim, maxDim) 132 - const cropperRes = await openCropper(store, { 133 - mediaType: 'photo', 134 - path, 135 - freeStyleCropEnabled: true, 136 - width, 137 - height, 138 - }) 139 - 140 - const img = await compressIfNeeded(cropperRes, maxSize) 141 - const permanentPath = await moveToPremanantPath(img.path) 142 - return permanentPath 143 - }
+8 -61
src/lib/media/picker.web.tsx
··· 1 1 /// <reference lib="dom" /> 2 2 3 - import {PickerOpts, CameraOpts, CropperOpts, PickedMedia} from './types' 4 - export type {PickedMedia} from './types' 3 + import {PickerOpts, CameraOpts, CropperOptions} from './types' 5 4 import {RootStoreModel} from 'state/index' 6 - import { 7 - scaleDownDimensions, 8 - getImageDim, 9 - Dim, 10 - compressIfNeeded, 11 - moveToPremanantPath, 12 - } from 'lib/media/manip' 5 + import {getImageDim} from 'lib/media/manip' 13 6 import {extractDataUriMime} from './util' 7 + import {Image as RNImage} from 'react-native-image-crop-picker' 14 8 15 9 interface PickedFile { 16 10 uri: string ··· 21 15 export async function openPicker( 22 16 _store: RootStoreModel, 23 17 opts: PickerOpts, 24 - ): Promise<PickedMedia[]> { 18 + ): Promise<RNImage[]> { 25 19 const res = await selectFile(opts) 26 20 const dim = await getImageDim(res.uri) 27 21 const mime = extractDataUriMime(res.uri) 28 22 return [ 29 23 { 30 - mediaType: 'photo', 31 24 path: res.uri, 32 25 mime, 33 26 size: res.size, ··· 40 33 export async function openCamera( 41 34 _store: RootStoreModel, 42 35 _opts: CameraOpts, 43 - ): Promise<PickedMedia> { 36 + ): Promise<RNImage> { 44 37 // const mediaType = opts.mediaType || 'photo' TODO 45 38 throw new Error('TODO') 46 39 } 47 40 48 41 export async function openCropper( 49 42 store: RootStoreModel, 50 - opts: CropperOpts, 51 - ): Promise<PickedMedia> { 43 + opts: CropperOptions, 44 + ): Promise<RNImage> { 52 45 // TODO handle more opts 53 46 return new Promise((resolve, reject) => { 54 47 store.shell.openModal({ 55 48 name: 'crop-image', 56 49 uri: opts.path, 57 - onSelect: (img?: PickedMedia) => { 50 + onSelect: (img?: RNImage) => { 58 51 if (img) { 59 52 resolve(img) 60 53 } else { ··· 64 57 }) 65 58 }) 66 59 } 67 - 68 - export async function pickImagesFlow( 69 - store: RootStoreModel, 70 - maxFiles: number, 71 - maxDim: Dim, 72 - maxSize: number, 73 - ) { 74 - const items = await openPicker(store, { 75 - multiple: true, 76 - maxFiles, 77 - mediaType: 'photo', 78 - }) 79 - const result = [] 80 - for (const image of items) { 81 - result.push( 82 - await cropAndCompressFlow(store, image.path, image, maxDim, maxSize), 83 - ) 84 - } 85 - return result 86 - } 87 - 88 - export async function cropAndCompressFlow( 89 - store: RootStoreModel, 90 - path: string, 91 - imgDim: Dim, 92 - maxDim: Dim, 93 - maxSize: number, 94 - ) { 95 - // choose target dimensions based on the original 96 - // this causes the photo cropper to start with the full image "selected" 97 - const {width, height} = scaleDownDimensions(imgDim, maxDim) 98 - const cropperRes = await openCropper(store, { 99 - mediaType: 'photo', 100 - path, 101 - freeStyleCropEnabled: true, 102 - width, 103 - height, 104 - }) 105 - 106 - const img = await compressIfNeeded(cropperRes, maxSize) 107 - const permanentPath = await moveToPremanantPath(img.path) 108 - return permanentPath 109 - } 110 - 111 - // helpers 112 - // = 113 60 114 61 /** 115 62 * Opens the select file dialog in the browser.
+9 -19
src/lib/media/types.ts
··· 1 + import {openCropper} from 'react-native-image-crop-picker' 2 + 3 + export interface Dimensions { 4 + width: number 5 + height: number 6 + } 7 + 1 8 export interface PickerOpts { 2 - mediaType?: 'photo' 9 + mediaType?: string 3 10 multiple?: boolean 4 11 maxFiles?: number 5 12 } 6 13 7 14 export interface CameraOpts { 8 - mediaType?: 'photo' 9 15 width: number 10 16 height: number 11 17 freeStyleCropEnabled?: boolean 12 18 cropperCircleOverlay?: boolean 13 19 } 14 20 15 - export interface CropperOpts { 16 - path: string 17 - mediaType?: 'photo' 18 - width: number 19 - height: number 20 - freeStyleCropEnabled?: boolean 21 - cropperCircleOverlay?: boolean 22 - } 23 - 24 - export interface PickedMedia { 25 - mediaType: 'photo' 26 - path: string 27 - mime: string 28 - size: number 29 - width: number 30 - height: number 31 - } 21 + export type CropperOptions = Parameters<typeof openCropper>[0]
+39 -1
src/lib/media/util.ts
··· 1 + import {Dimensions} from './types' 2 + 1 3 export function extractDataUriMime(uri: string): string { 2 4 return uri.substring(uri.indexOf(':') + 1, uri.indexOf(';')) 3 5 } 4 6 7 + // Fairly accurate estimate that is more performant 8 + // than decoding and checking length of URI 5 9 export function getDataUriSize(uri: string): number { 6 - return Math.round((uri.length * 3) / 4) // very rough estimate 10 + return Math.round((uri.length * 3) / 4) 11 + } 12 + 13 + export function scaleDownDimensions( 14 + dim: Dimensions, 15 + max: Dimensions, 16 + ): Dimensions { 17 + if (dim.width < max.width && dim.height < max.height) { 18 + return dim 19 + } 20 + const wScale = dim.width > max.width ? max.width / dim.width : 1 21 + const hScale = dim.height > max.height ? max.height / dim.height : 1 22 + if (wScale < hScale) { 23 + return {width: dim.width * wScale, height: dim.height * wScale} 24 + } 25 + return {width: dim.width * hScale, height: dim.height * hScale} 26 + } 27 + 28 + export function isUriImage(uri: string) { 29 + return /\.(jpg|jpeg|png).*$/.test(uri) 30 + } 31 + 32 + export function blobToDataUri(blob: Blob): Promise<string> { 33 + return new Promise((resolve, reject) => { 34 + const reader = new FileReader() 35 + reader.onloadend = () => { 36 + if (typeof reader.result === 'string') { 37 + resolve(reader.result) 38 + } else { 39 + reject(new Error('Failed to read blob')) 40 + } 41 + } 42 + reader.onerror = reject 43 + reader.readAsDataURL(blob) 44 + }) 7 45 }
+9 -9
src/state/models/cache/image-sizes.ts
··· 1 1 import {Image} from 'react-native' 2 - import {Dim} from 'lib/media/manip' 2 + import type {Dimensions} from 'lib/media/types' 3 3 4 4 export class ImageSizesCache { 5 - sizes: Map<string, Dim> = new Map() 6 - activeRequests: Map<string, Promise<Dim>> = new Map() 5 + sizes: Map<string, Dimensions> = new Map() 6 + activeRequests: Map<string, Promise<Dimensions>> = new Map() 7 7 8 8 constructor() {} 9 9 10 - get(uri: string): Dim | undefined { 10 + get(uri: string): Dimensions | undefined { 11 11 return this.sizes.get(uri) 12 12 } 13 13 14 - async fetch(uri: string): Promise<Dim> { 15 - const dim = this.sizes.get(uri) 16 - if (dim) { 17 - return dim 14 + async fetch(uri: string): Promise<Dimensions> { 15 + const Dimensions = this.sizes.get(uri) 16 + if (Dimensions) { 17 + return Dimensions 18 18 } 19 19 const prom = 20 20 this.activeRequests.get(uri) || 21 - new Promise<Dim>(resolve => { 21 + new Promise<Dimensions>(resolve => { 22 22 Image.getSize( 23 23 uri, 24 24 (width: number, height: number) => resolve({width, height}),
+3 -3
src/state/models/content/profile.ts
··· 1 1 import {makeAutoObservable, runInAction} from 'mobx' 2 - import {PickedMedia} from 'lib/media/picker' 3 2 import { 4 3 ComAtprotoLabelDefs, 5 4 AppBskyActorGetProfile as GetProfile, ··· 10 9 import * as apilib from 'lib/api/index' 11 10 import {cleanError} from 'lib/strings/errors' 12 11 import {FollowState} from '../cache/my-follows' 12 + import {Image as RNImage} from 'react-native-image-crop-picker' 13 13 14 14 export const ACTOR_TYPE_USER = 'app.bsky.system.actorUser' 15 15 ··· 122 122 123 123 async updateProfile( 124 124 updates: AppBskyActorProfile.Record, 125 - newUserAvatar: PickedMedia | undefined | null, 126 - newUserBanner: PickedMedia | undefined | null, 125 + newUserAvatar: RNImage | undefined | null, 126 + newUserBanner: RNImage | undefined | null, 127 127 ) { 128 128 await this.rootStore.agent.upsertProfile(async existing => { 129 129 existing = existing || {}
+85
src/state/models/media/gallery.ts
··· 1 + import {makeAutoObservable, runInAction} from 'mobx' 2 + import {RootStoreModel} from 'state/index' 3 + import {ImageModel} from './image' 4 + import {Image as RNImage} from 'react-native-image-crop-picker' 5 + import {openPicker} from 'lib/media/picker' 6 + import {getImageDim} from 'lib/media/manip' 7 + import {getDataUriSize} from 'lib/media/util' 8 + 9 + export class GalleryModel { 10 + images: ImageModel[] = [] 11 + 12 + constructor(public rootStore: RootStoreModel) { 13 + makeAutoObservable(this, { 14 + rootStore: false, 15 + }) 16 + } 17 + 18 + get isEmpty() { 19 + return this.size === 0 20 + } 21 + 22 + get size() { 23 + return this.images.length 24 + } 25 + 26 + get paths() { 27 + return this.images.map(image => 28 + image.compressed === undefined ? image.path : image.compressed.path, 29 + ) 30 + } 31 + 32 + async add(image_: RNImage) { 33 + if (this.size >= 4) { 34 + return 35 + } 36 + 37 + // Temporarily enforce uniqueness but can eventually also use index 38 + if (!this.images.some(i => i.path === image_.path)) { 39 + const image = new ImageModel(this.rootStore, image_) 40 + await image.compress() 41 + 42 + runInAction(() => { 43 + this.images.push(image) 44 + }) 45 + } 46 + } 47 + 48 + async paste(uri: string) { 49 + if (this.size >= 4) { 50 + return 51 + } 52 + 53 + const {width, height} = await getImageDim(uri) 54 + 55 + const image: RNImage = { 56 + path: uri, 57 + height, 58 + width, 59 + size: getDataUriSize(uri), 60 + mime: 'image/jpeg', 61 + } 62 + 63 + runInAction(() => { 64 + this.add(image) 65 + }) 66 + } 67 + 68 + crop(image: ImageModel) { 69 + image.crop() 70 + } 71 + 72 + remove(image: ImageModel) { 73 + const index = this.images.findIndex(image_ => image_.path === image.path) 74 + this.images.splice(index, 1) 75 + } 76 + 77 + async pick() { 78 + const images = await openPicker(this.rootStore, { 79 + multiple: true, 80 + maxFiles: 4 - this.images.length, 81 + }) 82 + 83 + await Promise.all(images.map(image => this.add(image))) 84 + } 85 + }
+85
src/state/models/media/image.ts
··· 1 + import {Image as RNImage} from 'react-native-image-crop-picker' 2 + import {RootStoreModel} from 'state/index' 3 + import {compressAndResizeImageForPost} from 'lib/media/manip' 4 + import {makeAutoObservable, runInAction} from 'mobx' 5 + import {openCropper} from 'lib/media/picker' 6 + import {POST_IMG_MAX} from 'lib/constants' 7 + import {scaleDownDimensions} from 'lib/media/util' 8 + 9 + // TODO: EXIF embed 10 + // Cases to consider: ExternalEmbed 11 + export class ImageModel implements RNImage { 12 + path: string 13 + mime = 'image/jpeg' 14 + width: number 15 + height: number 16 + size: number 17 + cropped?: RNImage = undefined 18 + compressed?: RNImage = undefined 19 + scaledWidth: number = POST_IMG_MAX.width 20 + scaledHeight: number = POST_IMG_MAX.height 21 + 22 + constructor(public rootStore: RootStoreModel, image: RNImage) { 23 + makeAutoObservable(this, { 24 + rootStore: false, 25 + }) 26 + 27 + this.path = image.path 28 + this.width = image.width 29 + this.height = image.height 30 + this.size = image.size 31 + this.calcScaledDimensions() 32 + } 33 + 34 + calcScaledDimensions() { 35 + const {width, height} = scaleDownDimensions( 36 + {width: this.width, height: this.height}, 37 + POST_IMG_MAX, 38 + ) 39 + 40 + this.scaledWidth = width 41 + this.scaledHeight = height 42 + } 43 + 44 + async crop() { 45 + try { 46 + const cropped = await openCropper(this.rootStore, { 47 + mediaType: 'photo', 48 + path: this.path, 49 + freeStyleCropEnabled: true, 50 + width: this.scaledWidth, 51 + height: this.scaledHeight, 52 + }) 53 + 54 + runInAction(() => { 55 + this.cropped = cropped 56 + }) 57 + } catch (err) { 58 + this.rootStore.log.error('Failed to crop photo', err) 59 + } 60 + 61 + this.compress() 62 + } 63 + 64 + async compress() { 65 + try { 66 + const {width, height} = scaleDownDimensions( 67 + this.cropped 68 + ? {width: this.cropped.width, height: this.cropped.height} 69 + : {width: this.width, height: this.height}, 70 + POST_IMG_MAX, 71 + ) 72 + const compressed = await compressAndResizeImageForPost({ 73 + ...(this.cropped === undefined ? this : this.cropped), 74 + width, 75 + height, 76 + }) 77 + 78 + runInAction(() => { 79 + this.compressed = compressed 80 + }) 81 + } catch (err) { 82 + this.rootStore.log.error('Failed to compress photo', err) 83 + } 84 + } 85 + }
+2 -2
src/state/models/ui/shell.ts
··· 3 3 import {makeAutoObservable} from 'mobx' 4 4 import {ProfileModel} from '../content/profile' 5 5 import {isObj, hasProp} from 'lib/type-guards' 6 - import {PickedMedia} from 'lib/media/types' 6 + import {Image} from 'lib/media/types' 7 7 8 8 export interface ConfirmModal { 9 9 name: 'confirm' ··· 38 38 export interface CropImageModal { 39 39 name: 'crop-image' 40 40 uri: string 41 - onSelect: (img?: PickedMedia) => void 41 + onSelect: (img?: Image) => void 42 42 } 43 43 44 44 export interface DeleteAccountModal {
+54 -78
src/view/com/composer/Composer.tsx
··· 1 - import React from 'react' 1 + import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react' 2 2 import {observer} from 'mobx-react-lite' 3 3 import { 4 4 ActivityIndicator, ··· 30 30 import {cleanError} from 'lib/strings/errors' 31 31 import {SelectPhotoBtn} from './photos/SelectPhotoBtn' 32 32 import {OpenCameraBtn} from './photos/OpenCameraBtn' 33 - import {SelectedPhotos} from './photos/SelectedPhotos' 34 33 import {usePalette} from 'lib/hooks/usePalette' 35 34 import QuoteEmbed from '../util/post-embeds/QuoteEmbed' 36 35 import {useExternalLinkFetch} from './useExternalLinkFetch' 37 36 import {isDesktopWeb} from 'platform/detection' 37 + import {GalleryModel} from 'state/models/media/gallery' 38 + import {Gallery} from './photos/Gallery' 38 39 39 40 const MAX_GRAPHEME_LENGTH = 300 40 41 42 + type Props = ComposerOpts & { 43 + onClose: () => void 44 + } 45 + 41 46 export const ComposePost = observer(function ComposePost({ 42 47 replyTo, 43 48 onPost, 44 49 onClose, 45 50 quote: initQuote, 46 - }: { 47 - replyTo?: ComposerOpts['replyTo'] 48 - onPost?: ComposerOpts['onPost'] 49 - onClose: () => void 50 - quote?: ComposerOpts['quote'] 51 - }) { 51 + }: Props) { 52 52 const {track} = useAnalytics() 53 53 const pal = usePalette('default') 54 54 const store = useStores() 55 - const textInput = React.useRef<TextInputRef>(null) 56 - const [isProcessing, setIsProcessing] = React.useState(false) 57 - const [processingState, setProcessingState] = React.useState('') 58 - const [error, setError] = React.useState('') 59 - const [richtext, setRichText] = React.useState(new RichText({text: ''})) 60 - const graphemeLength = React.useMemo( 61 - () => richtext.graphemeLength, 62 - [richtext], 63 - ) 64 - const [quote, setQuote] = React.useState<ComposerOpts['quote'] | undefined>( 55 + const textInput = useRef<TextInputRef>(null) 56 + const [isProcessing, setIsProcessing] = useState(false) 57 + const [processingState, setProcessingState] = useState('') 58 + const [error, setError] = useState('') 59 + const [richtext, setRichText] = useState(new RichText({text: ''})) 60 + const graphemeLength = useMemo(() => richtext.graphemeLength, [richtext]) 61 + const [quote, setQuote] = useState<ComposerOpts['quote'] | undefined>( 65 62 initQuote, 66 63 ) 67 64 const {extLink, setExtLink} = useExternalLinkFetch({setQuote}) 68 - const [suggestedLinks, setSuggestedLinks] = React.useState<Set<string>>( 69 - new Set(), 70 - ) 71 - const [selectedPhotos, setSelectedPhotos] = React.useState<string[]>([]) 65 + const [suggestedLinks, setSuggestedLinks] = useState<Set<string>>(new Set()) 66 + const gallery = useMemo(() => new GalleryModel(store), [store]) 72 67 73 - const autocompleteView = React.useMemo<UserAutocompleteModel>( 68 + const autocompleteView = useMemo<UserAutocompleteModel>( 74 69 () => new UserAutocompleteModel(store), 75 70 [store], 76 71 ) ··· 82 77 // is focused during unmount, an exception will throw (seems that a blur method isnt implemented) 83 78 // manually blurring before closing gets around that 84 79 // -prf 85 - const hackfixOnClose = React.useCallback(() => { 80 + const hackfixOnClose = useCallback(() => { 86 81 textInput.current?.blur() 87 82 onClose() 88 83 }, [textInput, onClose]) 89 84 90 85 // initial setup 91 - React.useEffect(() => { 86 + useEffect(() => { 92 87 autocompleteView.setup() 93 88 }, [autocompleteView]) 94 89 95 - React.useEffect(() => { 90 + useEffect(() => { 96 91 // HACK 97 92 // wait a moment before focusing the input to resolve some layout bugs with the keyboard-avoiding-view 98 93 // -prf ··· 109 104 } 110 105 }, []) 111 106 112 - const onPressContainer = React.useCallback(() => { 107 + const onPressContainer = useCallback(() => { 113 108 textInput.current?.focus() 114 109 }, [textInput]) 115 110 116 - const onSelectPhotos = React.useCallback( 117 - (photos: string[]) => { 118 - track('Composer:SelectedPhotos') 119 - setSelectedPhotos(photos) 120 - }, 121 - [track, setSelectedPhotos], 122 - ) 123 - 124 - const onPressAddLinkCard = React.useCallback( 111 + const onPressAddLinkCard = useCallback( 125 112 (uri: string) => { 126 113 setExtLink({uri, isLoading: true}) 127 114 }, 128 115 [setExtLink], 129 116 ) 130 117 131 - const onPhotoPasted = React.useCallback( 118 + const onPhotoPasted = useCallback( 132 119 async (uri: string) => { 133 - if (selectedPhotos.length >= 4) { 134 - return 135 - } 136 - onSelectPhotos([...selectedPhotos, uri]) 120 + track('Composer:PastedPhotos') 121 + gallery.paste(uri) 137 122 }, 138 - [selectedPhotos, onSelectPhotos], 123 + [gallery, track], 139 124 ) 140 125 141 - const onPressPublish = React.useCallback(async () => { 142 - if (isProcessing) { 126 + const onPressPublish = useCallback(async () => { 127 + if (isProcessing || richtext.graphemeLength > MAX_GRAPHEME_LENGTH) { 143 128 return 144 129 } 145 - if (richtext.graphemeLength > MAX_GRAPHEME_LENGTH) { 146 - return 147 - } 130 + 148 131 setError('') 149 - if (richtext.text.trim().length === 0 && selectedPhotos.length === 0) { 132 + 133 + if (richtext.text.trim().length === 0 && gallery.isEmpty) { 150 134 setError('Did you want to say anything?') 151 135 return false 152 136 } 137 + 153 138 setIsProcessing(true) 139 + 154 140 try { 155 141 await apilib.post(store, { 156 142 rawText: richtext.text, 157 143 replyTo: replyTo?.uri, 158 - images: selectedPhotos, 144 + images: gallery.paths, 159 145 quote: quote, 160 146 extLink: extLink, 161 147 onStateChange: setProcessingState, 162 148 knownHandles: autocompleteView.knownHandles, 163 149 }) 164 150 track('Create Post', { 165 - imageCount: selectedPhotos.length, 151 + imageCount: gallery.size, 166 152 }) 167 153 } catch (e: any) { 168 154 if (extLink) { ··· 191 177 hackfixOnClose, 192 178 onPost, 193 179 quote, 194 - selectedPhotos, 195 180 setExtLink, 196 181 store, 197 182 track, 183 + gallery, 198 184 ]) 199 185 200 186 const canPost = graphemeLength <= MAX_GRAPHEME_LENGTH 201 187 202 188 const selectTextInputPlaceholder = replyTo 203 189 ? 'Write your reply' 204 - : selectedPhotos.length !== 0 190 + : gallery.isEmpty 205 191 ? 'Write a comment' 206 192 : "What's up?" 207 193 194 + const canSelectImages = gallery.size <= 4 195 + const viewStyles = { 196 + paddingBottom: Platform.OS === 'android' ? insets.bottom : 0, 197 + paddingTop: Platform.OS === 'android' ? insets.top : 15, 198 + } 199 + 208 200 return ( 209 201 <KeyboardAvoidingView 210 202 testID="composePostView" 211 203 behavior={Platform.OS === 'ios' ? 'padding' : 'height'} 212 204 style={styles.outer}> 213 205 <TouchableWithoutFeedback onPressIn={onPressContainer}> 214 - <View 215 - style={[ 216 - s.flex1, 217 - { 218 - paddingBottom: Platform.OS === 'android' ? insets.bottom : 0, 219 - paddingTop: Platform.OS === 'android' ? insets.top : 15, 220 - }, 221 - ]}> 206 + <View style={[s.flex1, viewStyles]}> 222 207 <View style={styles.topbar}> 223 208 <TouchableOpacity 224 209 testID="composerCancelButton" ··· 301 286 /> 302 287 </View> 303 288 304 - <SelectedPhotos 305 - selectedPhotos={selectedPhotos} 306 - onSelectPhotos={onSelectPhotos} 307 - /> 308 - {selectedPhotos.length === 0 && extLink && ( 289 + <Gallery gallery={gallery} /> 290 + {gallery.isEmpty && extLink && ( 309 291 <ExternalEmbed 310 292 link={extLink} 311 293 onRemove={() => setExtLink(undefined)} ··· 317 299 </View> 318 300 ) : undefined} 319 301 </ScrollView> 320 - {!extLink && 321 - selectedPhotos.length === 0 && 322 - suggestedLinks.size > 0 ? ( 302 + {!extLink && suggestedLinks.size > 0 ? ( 323 303 <View style={s.mb5}> 324 304 {Array.from(suggestedLinks).map(url => ( 325 305 <TouchableOpacity ··· 335 315 </View> 336 316 ) : null} 337 317 <View style={[pal.border, styles.bottomBar]}> 338 - <SelectPhotoBtn 339 - enabled={selectedPhotos.length < 4} 340 - selectedPhotos={selectedPhotos} 341 - onSelectPhotos={setSelectedPhotos} 342 - /> 343 - <OpenCameraBtn 344 - enabled={selectedPhotos.length < 4} 345 - selectedPhotos={selectedPhotos} 346 - onSelectPhotos={setSelectedPhotos} 347 - /> 318 + {canSelectImages ? ( 319 + <> 320 + <SelectPhotoBtn gallery={gallery} /> 321 + <OpenCameraBtn gallery={gallery} /> 322 + </> 323 + ) : null} 348 324 <View style={s.flex1} /> 349 325 <CharProgress count={graphemeLength} /> 350 326 </View>
+5 -7
src/view/com/composer/ExternalEmbed.tsx
··· 2 2 import { 3 3 ActivityIndicator, 4 4 StyleSheet, 5 - TouchableWithoutFeedback, 5 + TouchableOpacity, 6 6 View, 7 7 } from 'react-native' 8 8 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 9 - import {BlurView} from '../util/BlurView' 10 9 import {AutoSizedImage} from '../util/images/AutoSizedImage' 11 10 import {Text} from '../util/text/Text' 12 11 import {s} from 'lib/styles' ··· 61 60 </Text> 62 61 )} 63 62 </View> 64 - <TouchableWithoutFeedback onPress={onRemove}> 65 - <BlurView style={styles.removeBtn} blurType="dark"> 66 - <FontAwesomeIcon size={18} icon="xmark" style={s.white} /> 67 - </BlurView> 68 - </TouchableWithoutFeedback> 63 + <TouchableOpacity style={styles.removeBtn} onPress={onRemove}> 64 + <FontAwesomeIcon size={18} icon="xmark" style={s.white} /> 65 + </TouchableOpacity> 69 66 </View> 70 67 ) 71 68 } ··· 92 89 right: 10, 93 90 width: 36, 94 91 height: 36, 92 + backgroundColor: 'rgba(0, 0, 0, 0.75)', 95 93 borderRadius: 18, 96 94 alignItems: 'center', 97 95 justifyContent: 'center',
+130
src/view/com/composer/photos/Gallery.tsx
··· 1 + import React, {useCallback} from 'react' 2 + import {GalleryModel} from 'state/models/media/gallery' 3 + import {observer} from 'mobx-react-lite' 4 + import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 5 + import {colors} from 'lib/styles' 6 + import {StyleSheet, TouchableOpacity, View} from 'react-native' 7 + import {ImageModel} from 'state/models/media/image' 8 + import {Image} from 'expo-image' 9 + 10 + interface Props { 11 + gallery: GalleryModel 12 + } 13 + 14 + export const Gallery = observer(function ({gallery}: Props) { 15 + const getImageStyle = useCallback(() => { 16 + switch (gallery.size) { 17 + case 1: 18 + return styles.image250 19 + case 2: 20 + return styles.image175 21 + default: 22 + return styles.image85 23 + } 24 + }, [gallery]) 25 + 26 + const imageStyle = getImageStyle() 27 + const handleRemovePhoto = useCallback( 28 + (image: ImageModel) => { 29 + gallery.remove(image) 30 + }, 31 + [gallery], 32 + ) 33 + 34 + const handleEditPhoto = useCallback( 35 + (image: ImageModel) => { 36 + gallery.crop(image) 37 + }, 38 + [gallery], 39 + ) 40 + 41 + return !gallery.isEmpty ? ( 42 + <View testID="selectedPhotosView" style={styles.gallery}> 43 + {gallery.images.map(image => 44 + image.compressed !== undefined ? ( 45 + <View 46 + key={`selected-image-${image.path}`} 47 + style={[styles.imageContainer, imageStyle]}> 48 + <View style={styles.imageControls}> 49 + <TouchableOpacity 50 + testID="cropPhotoButton" 51 + onPress={() => { 52 + handleEditPhoto(image) 53 + }} 54 + style={styles.imageControl}> 55 + <FontAwesomeIcon 56 + icon="pen" 57 + size={12} 58 + style={{color: colors.white}} 59 + /> 60 + </TouchableOpacity> 61 + <TouchableOpacity 62 + testID="removePhotoButton" 63 + onPress={() => handleRemovePhoto(image)} 64 + style={styles.imageControl}> 65 + <FontAwesomeIcon 66 + icon="xmark" 67 + size={16} 68 + style={{color: colors.white}} 69 + /> 70 + </TouchableOpacity> 71 + </View> 72 + 73 + <Image 74 + testID="selectedPhotoImage" 75 + style={[styles.image, imageStyle]} 76 + source={{ 77 + uri: image.compressed.path, 78 + }} 79 + /> 80 + </View> 81 + ) : null, 82 + )} 83 + </View> 84 + ) : null 85 + }) 86 + 87 + const styles = StyleSheet.create({ 88 + gallery: { 89 + flex: 1, 90 + flexDirection: 'row', 91 + marginTop: 16, 92 + }, 93 + imageContainer: { 94 + margin: 2, 95 + }, 96 + image: { 97 + resizeMode: 'cover', 98 + borderRadius: 8, 99 + }, 100 + image250: { 101 + width: 250, 102 + height: 250, 103 + }, 104 + image175: { 105 + width: 175, 106 + height: 175, 107 + }, 108 + image85: { 109 + width: 85, 110 + height: 85, 111 + }, 112 + imageControls: { 113 + position: 'absolute', 114 + display: 'flex', 115 + flexDirection: 'row', 116 + gap: 4, 117 + top: 8, 118 + right: 8, 119 + zIndex: 1, 120 + }, 121 + imageControl: { 122 + width: 24, 123 + height: 24, 124 + borderRadius: 12, 125 + backgroundColor: 'rgba(0, 0, 0, 0.75)', 126 + borderWidth: 0.5, 127 + alignItems: 'center', 128 + justifyContent: 'center', 129 + }, 130 + })
+18 -40
src/view/com/composer/photos/OpenCameraBtn.tsx
··· 1 - import React from 'react' 1 + import React, {useCallback} from 'react' 2 2 import {TouchableOpacity} from 'react-native' 3 3 import { 4 4 FontAwesomeIcon, ··· 10 10 import {s} from 'lib/styles' 11 11 import {isDesktopWeb} from 'platform/detection' 12 12 import {openCamera} from 'lib/media/picker' 13 - import {compressIfNeeded} from 'lib/media/manip' 14 13 import {useCameraPermission} from 'lib/hooks/usePermissions' 15 - import { 16 - POST_IMG_MAX_WIDTH, 17 - POST_IMG_MAX_HEIGHT, 18 - POST_IMG_MAX_SIZE, 19 - } from 'lib/constants' 14 + import {POST_IMG_MAX} from 'lib/constants' 15 + import {GalleryModel} from 'state/models/media/gallery' 20 16 21 17 const HITSLOP = {left: 10, top: 10, right: 10, bottom: 10} 22 18 23 - export function OpenCameraBtn({ 24 - enabled, 25 - selectedPhotos, 26 - onSelectPhotos, 27 - }: { 28 - enabled: boolean 29 - selectedPhotos: string[] 30 - onSelectPhotos: (v: string[]) => void 31 - }) { 19 + type Props = { 20 + gallery: GalleryModel 21 + } 22 + 23 + export function OpenCameraBtn({gallery}: Props) { 32 24 const pal = usePalette('default') 33 25 const {track} = useAnalytics() 34 26 const store = useStores() 35 27 const {requestCameraAccessIfNeeded} = useCameraPermission() 36 28 37 - const onPressTakePicture = React.useCallback(async () => { 29 + const onPressTakePicture = useCallback(async () => { 38 30 track('Composer:CameraOpened') 39 - if (!enabled) { 40 - return 41 - } 42 31 try { 43 32 if (!(await requestCameraAccessIfNeeded())) { 44 33 return 45 34 } 46 - const cameraRes = await openCamera(store, { 47 - mediaType: 'photo', 48 - width: POST_IMG_MAX_WIDTH, 49 - height: POST_IMG_MAX_HEIGHT, 35 + 36 + const img = await openCamera(store, { 37 + width: POST_IMG_MAX.width, 38 + height: POST_IMG_MAX.height, 50 39 freeStyleCropEnabled: true, 51 40 }) 52 - const img = await compressIfNeeded(cameraRes, POST_IMG_MAX_SIZE) 53 - onSelectPhotos([...selectedPhotos, img.path]) 41 + 42 + gallery.add(img) 54 43 } catch (err: any) { 55 44 // ignore 56 45 store.log.warn('Error using camera', err) 57 46 } 58 - }, [ 59 - track, 60 - store, 61 - onSelectPhotos, 62 - selectedPhotos, 63 - enabled, 64 - requestCameraAccessIfNeeded, 65 - ]) 47 + }, [gallery, track, store, requestCameraAccessIfNeeded]) 66 48 67 49 if (isDesktopWeb) { 68 - return <></> 50 + return null 69 51 } 70 52 71 53 return ( ··· 76 58 hitSlop={HITSLOP}> 77 59 <FontAwesomeIcon 78 60 icon="camera" 79 - style={ 80 - (enabled 81 - ? pal.link 82 - : [pal.textLight, s.dimmed]) as FontAwesomeIconStyle 83 - } 61 + style={pal.link as FontAwesomeIconStyle} 84 62 size={24} 85 63 /> 86 64 </TouchableOpacity>
+3
src/view/com/composer/photos/OpenCameraBtn.web.tsx
··· 1 + export function OpenCameraBtn() { 2 + return null 3 + }
+15 -69
src/view/com/composer/photos/SelectPhotoBtn.tsx
··· 1 - import React from 'react' 2 - import {Platform, TouchableOpacity} from 'react-native' 1 + import React, {useCallback} from 'react' 2 + import {TouchableOpacity} from 'react-native' 3 3 import { 4 4 FontAwesomeIcon, 5 5 FontAwesomeIconStyle, 6 6 } from '@fortawesome/react-native-fontawesome' 7 7 import {usePalette} from 'lib/hooks/usePalette' 8 8 import {useAnalytics} from 'lib/analytics' 9 - import {useStores} from 'state/index' 10 9 import {s} from 'lib/styles' 11 10 import {isDesktopWeb} from 'platform/detection' 12 - import {openPicker, cropAndCompressFlow, pickImagesFlow} from 'lib/media/picker' 13 11 import {usePhotoLibraryPermission} from 'lib/hooks/usePermissions' 14 - import { 15 - POST_IMG_MAX_WIDTH, 16 - POST_IMG_MAX_HEIGHT, 17 - POST_IMG_MAX_SIZE, 18 - } from 'lib/constants' 12 + import {GalleryModel} from 'state/models/media/gallery' 19 13 20 14 const HITSLOP = {left: 10, top: 10, right: 10, bottom: 10} 21 15 22 - export function SelectPhotoBtn({ 23 - enabled, 24 - selectedPhotos, 25 - onSelectPhotos, 26 - }: { 27 - enabled: boolean 28 - selectedPhotos: string[] 29 - onSelectPhotos: (v: string[]) => void 30 - }) { 16 + type Props = { 17 + gallery: GalleryModel 18 + } 19 + 20 + export function SelectPhotoBtn({gallery}: Props) { 31 21 const pal = usePalette('default') 32 22 const {track} = useAnalytics() 33 - const store = useStores() 34 23 const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission() 35 24 36 - const onPressSelectPhotos = React.useCallback(async () => { 25 + const onPressSelectPhotos = useCallback(async () => { 37 26 track('Composer:GalleryOpened') 38 - if (!enabled) { 27 + 28 + if (!isDesktopWeb && !(await requestPhotoAccessIfNeeded())) { 39 29 return 40 30 } 41 - if (isDesktopWeb) { 42 - const images = await pickImagesFlow( 43 - store, 44 - 4 - selectedPhotos.length, 45 - {width: POST_IMG_MAX_WIDTH, height: POST_IMG_MAX_HEIGHT}, 46 - POST_IMG_MAX_SIZE, 47 - ) 48 - onSelectPhotos([...selectedPhotos, ...images]) 49 - } else { 50 - if (!(await requestPhotoAccessIfNeeded())) { 51 - return 52 - } 53 - const items = await openPicker(store, { 54 - multiple: true, 55 - maxFiles: 4 - selectedPhotos.length, 56 - mediaType: 'photo', 57 - }) 58 - const result = [] 59 - for (const image of items) { 60 - if (Platform.OS === 'android') { 61 - result.push(image.path) 62 - continue 63 - } 64 - result.push( 65 - await cropAndCompressFlow( 66 - store, 67 - image.path, 68 - image, 69 - {width: POST_IMG_MAX_WIDTH, height: POST_IMG_MAX_HEIGHT}, 70 - POST_IMG_MAX_SIZE, 71 - ), 72 - ) 73 - } 74 - onSelectPhotos([...selectedPhotos, ...result]) 75 - } 76 - }, [ 77 - track, 78 - store, 79 - onSelectPhotos, 80 - selectedPhotos, 81 - enabled, 82 - requestPhotoAccessIfNeeded, 83 - ]) 31 + 32 + gallery.pick() 33 + }, [track, gallery, requestPhotoAccessIfNeeded]) 84 34 85 35 return ( 86 36 <TouchableOpacity ··· 90 40 hitSlop={HITSLOP}> 91 41 <FontAwesomeIcon 92 42 icon={['far', 'image']} 93 - style={ 94 - (enabled 95 - ? pal.link 96 - : [pal.textLight, s.dimmed]) as FontAwesomeIconStyle 97 - } 43 + style={pal.link as FontAwesomeIconStyle} 98 44 size={24} 99 45 /> 100 46 </TouchableOpacity>
-96
src/view/com/composer/photos/SelectedPhotos.tsx
··· 1 - import React, {useCallback} from 'react' 2 - import {StyleSheet, TouchableOpacity, View} from 'react-native' 3 - import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 4 - import {Image} from 'expo-image' 5 - import {colors} from 'lib/styles' 6 - 7 - export const SelectedPhotos = ({ 8 - selectedPhotos, 9 - onSelectPhotos, 10 - }: { 11 - selectedPhotos: string[] 12 - onSelectPhotos: (v: string[]) => void 13 - }) => { 14 - const imageStyle = 15 - selectedPhotos.length === 1 16 - ? styles.image250 17 - : selectedPhotos.length === 2 18 - ? styles.image175 19 - : styles.image85 20 - 21 - const handleRemovePhoto = useCallback( 22 - item => { 23 - onSelectPhotos(selectedPhotos.filter(filterItem => filterItem !== item)) 24 - }, 25 - [selectedPhotos, onSelectPhotos], 26 - ) 27 - 28 - return selectedPhotos.length !== 0 ? ( 29 - <View testID="selectedPhotosView" style={styles.gallery}> 30 - {selectedPhotos.length !== 0 && 31 - selectedPhotos.map((item, index) => ( 32 - <View 33 - key={`selected-image-${index}`} 34 - style={[styles.imageContainer, imageStyle]}> 35 - <TouchableOpacity 36 - testID="removePhotoButton" 37 - onPress={() => handleRemovePhoto(item)} 38 - style={styles.removePhotoButton}> 39 - <FontAwesomeIcon 40 - icon="xmark" 41 - size={16} 42 - style={{color: colors.white}} 43 - /> 44 - </TouchableOpacity> 45 - 46 - <Image 47 - testID="selectedPhotoImage" 48 - style={[styles.image, imageStyle]} 49 - source={{uri: item}} 50 - /> 51 - </View> 52 - ))} 53 - </View> 54 - ) : null 55 - } 56 - 57 - const styles = StyleSheet.create({ 58 - gallery: { 59 - flex: 1, 60 - flexDirection: 'row', 61 - marginTop: 16, 62 - }, 63 - imageContainer: { 64 - margin: 2, 65 - }, 66 - image: { 67 - resizeMode: 'cover', 68 - borderRadius: 8, 69 - }, 70 - image250: { 71 - width: 250, 72 - height: 250, 73 - }, 74 - image175: { 75 - width: 175, 76 - height: 175, 77 - }, 78 - image85: { 79 - width: 85, 80 - height: 85, 81 - }, 82 - removePhotoButton: { 83 - position: 'absolute', 84 - top: 8, 85 - right: 8, 86 - width: 24, 87 - height: 24, 88 - borderRadius: 12, 89 - alignItems: 'center', 90 - justifyContent: 'center', 91 - backgroundColor: colors.black, 92 - zIndex: 1, 93 - borderColor: colors.gray4, 94 - borderWidth: 0.5, 95 - }, 96 - })
+56 -55
src/view/com/composer/text-input/TextInput.tsx
··· 1 - import React from 'react' 1 + import React, {forwardRef, useCallback, useEffect, useRef, useMemo} from 'react' 2 2 import { 3 3 NativeSyntheticEvent, 4 4 StyleSheet, ··· 14 14 import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete' 15 15 import {Autocomplete} from './mobile/Autocomplete' 16 16 import {Text} from 'view/com/util/text/Text' 17 - import {useStores} from 'state/index' 18 17 import {cleanError} from 'lib/strings/errors' 19 - import {getImageDim} from 'lib/media/manip' 20 - import {cropAndCompressFlow} from 'lib/media/picker' 21 18 import {getMentionAt, insertMentionAt} from 'lib/strings/mention-manip' 22 - import { 23 - POST_IMG_MAX_WIDTH, 24 - POST_IMG_MAX_HEIGHT, 25 - POST_IMG_MAX_SIZE, 26 - } from 'lib/constants' 27 19 import {usePalette} from 'lib/hooks/usePalette' 28 20 import {useTheme} from 'lib/ThemeContext' 21 + import {isUriImage} from 'lib/media/util' 22 + import {downloadAndResize} from 'lib/media/manip' 23 + import {POST_IMG_MAX} from 'lib/constants' 29 24 30 25 export interface TextInputRef { 31 26 focus: () => void ··· 48 43 end: number 49 44 } 50 45 51 - export const TextInput = React.forwardRef( 46 + export const TextInput = forwardRef( 52 47 ( 53 48 { 54 49 richtext, ··· 63 58 ref, 64 59 ) => { 65 60 const pal = usePalette('default') 66 - const store = useStores() 67 - const textInput = React.useRef<PasteInputRef>(null) 68 - const textInputSelection = React.useRef<Selection>({start: 0, end: 0}) 61 + const textInput = useRef<PasteInputRef>(null) 62 + const textInputSelection = useRef<Selection>({start: 0, end: 0}) 69 63 const theme = useTheme() 70 64 71 65 React.useImperativeHandle(ref, () => ({ ··· 73 67 blur: () => textInput.current?.blur(), 74 68 })) 75 69 76 - React.useEffect(() => { 70 + useEffect(() => { 77 71 // HACK 78 72 // wait a moment before focusing the input to resolve some layout bugs with the keyboard-avoiding-view 79 73 // -prf ··· 90 84 } 91 85 }, []) 92 86 93 - const onChangeText = React.useCallback( 94 - (newText: string) => { 87 + const onChangeText = useCallback( 88 + async (newText: string) => { 95 89 const newRt = new RichText({text: newText}) 96 90 newRt.detectFacetsWithoutResolution() 97 91 setRichText(newRt) ··· 108 102 } 109 103 110 104 const set: Set<string> = new Set() 105 + 111 106 if (newRt.facets) { 112 107 for (const facet of newRt.facets) { 113 108 for (const feature of facet.features) { 114 109 if (AppBskyRichtextFacet.isLink(feature)) { 115 - set.add(feature.uri) 110 + if (isUriImage(feature.uri)) { 111 + const res = await downloadAndResize({ 112 + uri: feature.uri, 113 + width: POST_IMG_MAX.width, 114 + height: POST_IMG_MAX.height, 115 + mode: 'contain', 116 + maxSize: POST_IMG_MAX.size, 117 + timeout: 15e3, 118 + }) 119 + 120 + if (res !== undefined) { 121 + onPhotoPasted(res.path) 122 + } 123 + } else { 124 + set.add(feature.uri) 125 + } 116 126 } 117 127 } 118 128 } 119 129 } 130 + 120 131 if (!isEqual(set, suggestedLinks)) { 121 132 onSuggestedLinksChanged(set) 122 133 } 123 134 }, 124 - [setRichText, autocompleteView, suggestedLinks, onSuggestedLinksChanged], 135 + [ 136 + setRichText, 137 + autocompleteView, 138 + suggestedLinks, 139 + onSuggestedLinksChanged, 140 + onPhotoPasted, 141 + ], 125 142 ) 126 143 127 - const onPaste = React.useCallback( 144 + const onPaste = useCallback( 128 145 async (err: string | undefined, files: PastedFile[]) => { 129 146 if (err) { 130 147 return onError(cleanError(err)) 131 148 } 149 + 132 150 const uris = files.map(f => f.uri) 133 - const imgUri = uris.find(uri => /\.(jpe?g|png)$/.test(uri)) 134 - if (imgUri) { 135 - let imgDim 136 - try { 137 - imgDim = await getImageDim(imgUri) 138 - } catch (e) { 139 - imgDim = {width: POST_IMG_MAX_WIDTH, height: POST_IMG_MAX_HEIGHT} 140 - } 141 - const finalImgPath = await cropAndCompressFlow( 142 - store, 143 - imgUri, 144 - imgDim, 145 - {width: POST_IMG_MAX_WIDTH, height: POST_IMG_MAX_HEIGHT}, 146 - POST_IMG_MAX_SIZE, 147 - ) 148 - onPhotoPasted(finalImgPath) 151 + const uri = uris.find(isUriImage) 152 + 153 + if (uri) { 154 + onPhotoPasted(uri) 149 155 } 150 156 }, 151 - [store, onError, onPhotoPasted], 157 + [onError, onPhotoPasted], 152 158 ) 153 159 154 - const onSelectionChange = React.useCallback( 160 + const onSelectionChange = useCallback( 155 161 (evt: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => { 156 162 // NOTE we track the input selection using a ref to avoid excessive renders -prf 157 163 textInputSelection.current = evt.nativeEvent.selection ··· 159 165 [textInputSelection], 160 166 ) 161 167 162 - const onSelectAutocompleteItem = React.useCallback( 168 + const onSelectAutocompleteItem = useCallback( 163 169 (item: string) => { 164 170 onChangeText( 165 171 insertMentionAt( ··· 173 179 [onChangeText, richtext, autocompleteView], 174 180 ) 175 181 176 - const textDecorated = React.useMemo(() => { 182 + const textDecorated = useMemo(() => { 177 183 let i = 0 178 - return Array.from(richtext.segments()).map(segment => { 179 - if (!segment.facet) { 180 - return ( 181 - <Text key={i++} style={[pal.text, styles.textInputFormatting]}> 182 - {segment.text} 183 - </Text> 184 - ) 185 - } else { 186 - return ( 187 - <Text key={i++} style={[pal.link, styles.textInputFormatting]}> 188 - {segment.text} 189 - </Text> 190 - ) 191 - } 192 - }) 184 + 185 + return Array.from(richtext.segments()).map(segment => ( 186 + <Text 187 + key={i++} 188 + style={[ 189 + !segment.facet ? pal.text : pal.link, 190 + styles.textInputFormatting, 191 + ]}> 192 + {segment.text} 193 + </Text> 194 + )) 193 195 }, [richtext, pal.link, pal.text]) 194 196 195 197 return ( ··· 223 225 textInput: { 224 226 flex: 1, 225 227 width: '100%', 226 - minHeight: 80, 227 228 padding: 5, 228 229 paddingBottom: 20, 229 230 marginLeft: 8,
+41 -1
src/view/com/composer/text-input/TextInput.web.tsx
··· 12 12 import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete' 13 13 import {createSuggestion} from './web/Autocomplete' 14 14 import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle' 15 + import {isUriImage, blobToDataUri} from 'lib/media/util' 15 16 16 17 export interface TextInputRef { 17 18 focus: () => void ··· 37 38 suggestedLinks, 38 39 autocompleteView, 39 40 setRichText, 40 - // onPhotoPasted, TODO 41 + onPhotoPasted, 41 42 onSuggestedLinksChanged, 42 43 }: // onError, TODO 43 44 TextInputProps, ··· 72 73 attributes: { 73 74 class: modeClass, 74 75 }, 76 + handlePaste: (_, event) => { 77 + const items = event.clipboardData?.items 78 + 79 + if (items === undefined) { 80 + return 81 + } 82 + 83 + getImageFromUri(items, onPhotoPasted) 84 + }, 75 85 }, 76 86 content: richtext.text.toString(), 77 87 autofocus: true, ··· 147 157 marginBottom: 10, 148 158 }, 149 159 }) 160 + 161 + function getImageFromUri( 162 + items: DataTransferItemList, 163 + callback: (uri: string) => void, 164 + ) { 165 + for (let index = 0; index < items.length; index++) { 166 + const item = items[index] 167 + const {kind, type} = item 168 + 169 + if (type === 'text/plain') { 170 + item.getAsString(async itemString => { 171 + if (isUriImage(itemString)) { 172 + const response = await fetch(itemString) 173 + const blob = await response.blob() 174 + blobToDataUri(blob).then(callback, err => console.error(err)) 175 + } 176 + }) 177 + } 178 + 179 + if (kind === 'file') { 180 + const file = item.getAsFile() 181 + 182 + if (file instanceof Blob) { 183 + blobToDataUri(new Blob([file], {type: item.type})).then(callback, err => 184 + console.error(err), 185 + ) 186 + } 187 + } 188 + } 189 + }
+4 -4
src/view/com/composer/useExternalLinkFetch.ts
··· 6 6 import {downloadAndResize} from 'lib/media/manip' 7 7 import {isBskyPostUrl} from 'lib/strings/url-helpers' 8 8 import {ComposerOpts} from 'state/models/ui/shell' 9 + import {POST_IMG_MAX} from 'lib/constants' 9 10 10 11 export function useExternalLinkFetch({ 11 12 setQuote, ··· 55 56 return cleanup 56 57 } 57 58 if (extLink.isLoading && extLink.meta?.image && !extLink.localThumb) { 58 - console.log('attempting download') 59 59 downloadAndResize({ 60 60 uri: extLink.meta.image, 61 - width: 2000, 62 - height: 2000, 61 + width: POST_IMG_MAX.width, 62 + height: POST_IMG_MAX.height, 63 63 mode: 'contain', 64 - maxSize: 1000000, 64 + maxSize: POST_IMG_MAX.size, 65 65 timeout: 15e3, 66 66 }) 67 67 .catch(() => undefined)
+7 -7
src/view/com/modals/EditProfile.tsx
··· 8 8 } from 'react-native' 9 9 import LinearGradient from 'react-native-linear-gradient' 10 10 import {ScrollView, TextInput} from './util' 11 - import {PickedMedia} from '../../../lib/media/picker' 11 + import {Image as RNImage} from 'react-native-image-crop-picker' 12 12 import {Text} from '../util/text/Text' 13 13 import {ErrorMessage} from '../util/error/ErrorMessage' 14 14 import {useStores} from 'state/index' ··· 53 53 profileView.avatar, 54 54 ) 55 55 const [newUserBanner, setNewUserBanner] = useState< 56 - PickedMedia | undefined | null 56 + RNImage | undefined | null 57 57 >() 58 58 const [newUserAvatar, setNewUserAvatar] = useState< 59 - PickedMedia | undefined | null 59 + RNImage | undefined | null 60 60 >() 61 61 const onPressCancel = () => { 62 62 store.shell.closeModal() 63 63 } 64 - const onSelectNewAvatar = async (img: PickedMedia | null) => { 64 + const onSelectNewAvatar = async (img: RNImage | null) => { 65 65 track('EditProfile:AvatarSelected') 66 66 try { 67 67 // if img is null, user selected "remove avatar" ··· 71 71 return 72 72 } 73 73 const finalImg = await compressIfNeeded(img, 1000000) 74 - setNewUserAvatar({mediaType: 'photo', ...finalImg}) 74 + setNewUserAvatar(finalImg) 75 75 setUserAvatar(finalImg.path) 76 76 } catch (e: any) { 77 77 setError(cleanError(e)) 78 78 } 79 79 } 80 - const onSelectNewBanner = async (img: PickedMedia | null) => { 80 + const onSelectNewBanner = async (img: RNImage | null) => { 81 81 if (!img) { 82 82 setNewUserBanner(null) 83 83 setUserBanner(null) ··· 86 86 track('EditProfile:BannerSelected') 87 87 try { 88 88 const finalImg = await compressIfNeeded(img, 1000000) 89 - setNewUserBanner({mediaType: 'photo', ...finalImg}) 89 + setNewUserBanner(finalImg) 90 90 setUserBanner(finalImg.path) 91 91 } catch (e: any) { 92 92 setError(cleanError(e))
+4 -8
src/view/com/modals/crop-image/CropImage.web.tsx
··· 4 4 import {Slider} from '@miblanchard/react-native-slider' 5 5 import LinearGradient from 'react-native-linear-gradient' 6 6 import {Text} from 'view/com/util/text/Text' 7 - import {PickedMedia} from 'lib/media/types' 7 + import {Dimensions, Image} from 'lib/media/types' 8 8 import {getDataUriSize} from 'lib/media/util' 9 9 import {s, gradients} from 'lib/styles' 10 10 import {useStores} from 'state/index' ··· 16 16 Wide = 'wide', 17 17 Tall = 'tall', 18 18 } 19 - interface Dim { 20 - width: number 21 - height: number 22 - } 23 - const DIMS: Record<string, Dim> = { 19 + 20 + const DIMS: Record<string, Dimensions> = { 24 21 [AspectRatio.Square]: {width: 1000, height: 1000}, 25 22 [AspectRatio.Wide]: {width: 1000, height: 750}, 26 23 [AspectRatio.Tall]: {width: 750, height: 1000}, ··· 33 30 onSelect, 34 31 }: { 35 32 uri: string 36 - onSelect: (img?: PickedMedia) => void 33 + onSelect: (img?: Image) => void 37 34 }) { 38 35 const store = useStores() 39 36 const pal = usePalette('default') ··· 52 49 if (canvas) { 53 50 const dataUri = canvas.toDataURL('image/jpeg') 54 51 onSelect({ 55 - mediaType: 'photo', 56 52 path: dataUri, 57 53 mime: 'image/jpeg', 58 54 size: getDataUriSize(dataUri),
+5 -11
src/view/com/util/UserAvatar.tsx
··· 4 4 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 5 5 import {IconProp} from '@fortawesome/fontawesome-svg-core' 6 6 import {HighPriorityImage} from 'view/com/util/images/Image' 7 - import { 8 - openCamera, 9 - openCropper, 10 - openPicker, 11 - PickedMedia, 12 - } from '../../../lib/media/picker' 7 + import {openCamera, openCropper, openPicker} from '../../../lib/media/picker' 13 8 import { 14 9 usePhotoLibraryPermission, 15 10 useCameraPermission, ··· 19 14 import {DropdownButton} from './forms/DropdownButton' 20 15 import {usePalette} from 'lib/hooks/usePalette' 21 16 import {isWeb} from 'platform/detection' 17 + import {Image as RNImage} from 'react-native-image-crop-picker' 22 18 23 19 function DefaultAvatar({size}: {size: number}) { 24 20 return ( ··· 50 46 size: number 51 47 avatar?: string | null 52 48 hasWarning?: boolean 53 - onSelectNewAvatar?: (img: PickedMedia | null) => void 49 + onSelectNewAvatar?: (img: RNImage | null) => void 54 50 }) { 55 51 const store = useStores() 56 52 const pal = usePalette('default') ··· 68 64 } 69 65 onSelectNewAvatar?.( 70 66 await openCamera(store, { 71 - mediaType: 'photo', 72 67 width: 1000, 73 68 height: 1000, 74 69 cropperCircleOverlay: true, ··· 84 79 if (!(await requestPhotoAccessIfNeeded())) { 85 80 return 86 81 } 87 - const items = await openPicker(store, { 88 - mediaType: 'photo', 89 - }) 82 + const items = await openPicker(store) 83 + 90 84 onSelectNewAvatar?.( 91 85 await openCropper(store, { 92 86 mediaType: 'photo',
+4 -11
src/view/com/util/UserBanner.tsx
··· 4 4 import {IconProp} from '@fortawesome/fontawesome-svg-core' 5 5 import {Image} from 'expo-image' 6 6 import {colors} from 'lib/styles' 7 - import { 8 - openCamera, 9 - openCropper, 10 - openPicker, 11 - PickedMedia, 12 - } from '../../../lib/media/picker' 7 + import {openCamera, openCropper, openPicker} from '../../../lib/media/picker' 8 + import {Image as TImage} from 'lib/media/types' 13 9 import {useStores} from 'state/index' 14 10 import { 15 11 usePhotoLibraryPermission, ··· 24 20 onSelectNewBanner, 25 21 }: { 26 22 banner?: string | null 27 - onSelectNewBanner?: (img: PickedMedia | null) => void 23 + onSelectNewBanner?: (img: TImage | null) => void 28 24 }) { 29 25 const store = useStores() 30 26 const pal = usePalette('default') ··· 42 38 } 43 39 onSelectNewBanner?.( 44 40 await openCamera(store, { 45 - mediaType: 'photo', 46 41 // compressImageMaxWidth: 3000, TODO needed? 47 42 width: 3000, 48 43 // compressImageMaxHeight: 1000, TODO needed? ··· 59 54 if (!(await requestPhotoAccessIfNeeded())) { 60 55 return 61 56 } 62 - const items = await openPicker(store, { 63 - mediaType: 'photo', 64 - }) 57 + const items = await openPicker(store) 65 58 onSelectNewBanner?.( 66 59 await openCropper(store, { 67 60 mediaType: 'photo',
+4 -8
src/view/com/util/images/ImageLayoutGrid.tsx
··· 1 - import React from 'react' 1 + import {Dimensions} from 'lib/media/types' 2 + import React, {useState} from 'react' 2 3 import { 3 4 LayoutChangeEvent, 4 5 StyleProp, ··· 10 11 import {Image, ImageStyle} from 'expo-image' 11 12 12 13 export const DELAY_PRESS_IN = 500 13 - 14 - interface Dim { 15 - width: number 16 - height: number 17 - } 18 14 19 15 export type ImageLayoutGridType = 'two' | 'three' | 'four' 20 16 ··· 33 29 onPressIn?: (index: number) => void 34 30 style?: StyleProp<ViewStyle> 35 31 }) { 36 - const [containerInfo, setContainerInfo] = React.useState<Dim | undefined>() 32 + const [containerInfo, setContainerInfo] = useState<Dimensions | undefined>() 37 33 38 34 const onLayout = (evt: LayoutChangeEvent) => { 39 35 setContainerInfo({ ··· 71 67 onPress?: (index: number) => void 72 68 onLongPress?: (index: number) => void 73 69 onPressIn?: (index: number) => void 74 - containerInfo: Dim 70 + containerInfo: Dimensions 75 71 }) { 76 72 const size1 = React.useMemo<ImageStyle>(() => { 77 73 if (type === 'three') {