diff --git a/.gitignore b/.gitignore index 96a43e93..bab37d2a 100644 --- a/.gitignore +++ b/.gitignore @@ -82,6 +82,9 @@ web-build/ # Temporary files created by Metro to check the health of the file watcher .metro-health-check* +# VSCode +.vscode + # gitignore and github actions !.gitignore !.github diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index 457921d6..1b12f29c 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -10,8 +10,8 @@ import { import {AtUri} from '@atproto/api' import {RootStoreModel} from 'state/models/root-store' import {isNetworkError} from 'lib/strings/errors' +import {Image} from 'lib/media/types' import {LinkMeta} from '../link-meta/link-meta' -import {Image} from '../media/manip' import {isWeb} from 'platform/detection' export interface ExternalEmbedDraft { diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 0cde9b01..d49d8c75 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -161,6 +161,8 @@ export function SUGGESTED_FOLLOWS(serviceUrl: string) { } } -export const POST_IMG_MAX_WIDTH = 2000 -export const POST_IMG_MAX_HEIGHT = 2000 -export const POST_IMG_MAX_SIZE = 1000000 +export const POST_IMG_MAX = { + width: 2000, + height: 2000, + size: 1000000, +} diff --git a/src/lib/media/manip.ts b/src/lib/media/manip.ts index 6ff8b691..f77b861e 100644 --- a/src/lib/media/manip.ts +++ b/src/lib/media/manip.ts @@ -1,13 +1,77 @@ import RNFetchBlob from 'rn-fetch-blob' import ImageResizer from '@bam.tech/react-native-image-resizer' import {Image as RNImage, Share} from 'react-native' +import {Image} from 'react-native-image-crop-picker' import RNFS from 'react-native-fs' import uuid from 'react-native-uuid' import * as Toast from 'view/com/util/Toast' +import {Dimensions} from './types' +import {POST_IMG_MAX} from 'lib/constants' +import {isAndroid} from 'platform/detection' -export interface Dim { - width: number - height: number +export async function compressAndResizeImageForPost( + image: Image, +): Promise { + const uri = `file://${image.path}` + let resized: Omit + + for (let i = 0; i < 9; i++) { + const quality = 100 - i * 10 + + try { + resized = await ImageResizer.createResizedImage( + uri, + POST_IMG_MAX.width, + POST_IMG_MAX.height, + 'JPEG', + quality, + undefined, + undefined, + undefined, + {mode: 'cover'}, + ) + } catch (err) { + throw new Error(`Failed to resize: ${err}`) + } + + if (resized.size < POST_IMG_MAX.size) { + const path = await moveToPermanentPath(resized.path) + + return { + path, + mime: 'image/jpeg', + size: resized.size, + height: resized.height, + width: resized.width, + } + } + } + + throw new Error( + `This image is too big! We couldn't compress it down to ${POST_IMG_MAX.size} bytes`, + ) +} + +export async function compressIfNeeded( + img: Image, + maxSize: number = 1000000, +): Promise { + const origUri = `file://${img.path}` + if (img.size < maxSize) { + return img + } + const resizedImage = await doResize(origUri, { + width: img.width, + height: img.height, + mode: 'stretch', + maxSize, + }) + const finalImageMovedPath = await moveToPermanentPath(resizedImage.path) + const finalImg = { + ...resizedImage, + path: finalImageMovedPath, + } + return finalImg } export interface DownloadAndResizeOpts { @@ -19,14 +83,6 @@ export interface DownloadAndResizeOpts { timeout: number } -export interface Image { - path: string - mime: string - size: number - width: number - height: number -} - export async function downloadAndResize(opts: DownloadAndResizeOpts) { let appendExt = 'jpeg' try { @@ -55,7 +111,7 @@ export async function downloadAndResize(opts: DownloadAndResizeOpts) { localUri = `file://${localUri}` } - return await resize(localUri, opts) + return await doResize(localUri, opts) } finally { if (downloadRes) { downloadRes.flush() @@ -63,79 +119,6 @@ export async function downloadAndResize(opts: DownloadAndResizeOpts) { } } -export interface ResizeOpts { - width: number - height: number - mode: 'contain' | 'cover' | 'stretch' - maxSize: number -} - -export async function resize( - localUri: string, - opts: ResizeOpts, -): Promise { - for (let i = 0; i < 9; i++) { - const quality = 100 - i * 10 - const resizeRes = await ImageResizer.createResizedImage( - localUri, - opts.width, - opts.height, - 'JPEG', - quality, - undefined, - undefined, - undefined, - {mode: opts.mode}, - ) - if (resizeRes.size < opts.maxSize) { - return { - path: resizeRes.path, - mime: 'image/jpeg', - size: resizeRes.size, - width: resizeRes.width, - height: resizeRes.height, - } - } - } - throw new Error( - `This image is too big! We couldn't compress it down to ${opts.maxSize} bytes`, - ) -} - -export async function compressIfNeeded( - img: Image, - maxSize: number, -): Promise { - const origUri = `file://${img.path}` - if (img.size < maxSize) { - return img - } - const resizedImage = await resize(origUri, { - width: img.width, - height: img.height, - mode: 'stretch', - maxSize, - }) - const finalImageMovedPath = await moveToPremanantPath(resizedImage.path) - const finalImg = { - ...resizedImage, - path: finalImageMovedPath, - } - return finalImg -} - -export function scaleDownDimensions(dim: Dim, max: Dim): Dim { - if (dim.width < max.width && dim.height < max.height) { - return dim - } - let wScale = dim.width > max.width ? max.width / dim.width : 1 - let hScale = dim.height > max.height ? max.height / dim.height : 1 - if (wScale < hScale) { - return {width: dim.width * wScale, height: dim.height * wScale} - } - return {width: dim.width * hScale, height: dim.height * hScale} -} - export async function saveImageModal({uri}: {uri: string}) { const downloadResponse = await RNFetchBlob.config({ fileCache: true, @@ -154,19 +137,7 @@ export async function saveImageModal({uri}: {uri: string}) { RNFS.unlink(imagePath) } -export async function moveToPremanantPath(path: string) { - /* - Since this package stores images in a temp directory, we need to move the file to a permanent location. - Relevant: IOS bug when trying to open a second time: - https://github.com/ivpusic/react-native-image-crop-picker/issues/1199 - */ - const filename = uuid.v4() - const destinationPath = `${RNFS.TemporaryDirectoryPath}/${filename}` - RNFS.moveFile(path, destinationPath) - return destinationPath -} - -export function getImageDim(path: string): Promise { +export function getImageDim(path: string): Promise { return new Promise((resolve, reject) => { RNImage.getSize( path, @@ -177,3 +148,64 @@ export function getImageDim(path: string): Promise { ) }) } + +// internal methods +// = + +interface DoResizeOpts { + width: number + height: number + mode: 'contain' | 'cover' | 'stretch' + maxSize: number +} + +async function doResize(localUri: string, opts: DoResizeOpts): Promise { + for (let i = 0; i < 9; i++) { + const quality = 100 - i * 10 + const resizeRes = await ImageResizer.createResizedImage( + localUri, + opts.width, + opts.height, + 'JPEG', + quality, + undefined, + undefined, + undefined, + {mode: opts.mode}, + ) + if (resizeRes.size < opts.maxSize) { + return { + path: normalizePath(resizeRes.path), + mime: 'image/jpeg', + size: resizeRes.size, + width: resizeRes.width, + height: resizeRes.height, + } + } + } + throw new Error( + `This image is too big! We couldn't compress it down to ${opts.maxSize} bytes`, + ) +} + +async function moveToPermanentPath(path: string): Promise { + /* + Since this package stores images in a temp directory, we need to move the file to a permanent location. + Relevant: IOS bug when trying to open a second time: + https://github.com/ivpusic/react-native-image-crop-picker/issues/1199 + */ + const filename = uuid.v4() + + const destinationPath = `${RNFS.TemporaryDirectoryPath}/${filename}` + await RNFS.moveFile(path, destinationPath) + return normalizePath(destinationPath) +} + +function normalizePath(str: string): string { + if (isAndroid) { + if (!str.startsWith('file://')) { + return `file://${str}` + } + } + return str +} diff --git a/src/lib/media/manip.web.ts b/src/lib/media/manip.web.ts index cd0bb3bc..85f6b613 100644 --- a/src/lib/media/manip.web.ts +++ b/src/lib/media/manip.web.ts @@ -1,6 +1,40 @@ -// import {Share} from 'react-native' -// import * as Toast from 'view/com/util/Toast' -import {extractDataUriMime, getDataUriSize} from './util' +import {Dimensions} from './types' +import {Image as RNImage} from 'react-native-image-crop-picker' +import {getDataUriSize, blobToDataUri} from './util' +import {POST_IMG_MAX} from 'lib/constants' + +export async function compressAndResizeImageForPost({ + path, + width, + height, +}: { + path: string + width: number + height: number +}): Promise { + // Compression is handled in `doResize` via `quality` + return await doResize(path, { + width, + height, + maxSize: POST_IMG_MAX.size, + mode: 'stretch', + }) +} + +export async function compressIfNeeded( + img: RNImage, + maxSize: number, +): Promise { + if (img.size < maxSize) { + return img + } + return await doResize(img.path, { + width: img.width, + height: img.height, + mode: 'stretch', + maxSize, + }) +} export interface DownloadAndResizeOpts { uri: string @@ -11,14 +45,6 @@ export interface DownloadAndResizeOpts { timeout: number } -export interface Image { - path: string - mime: string - size: number - width: number - height: number -} - export async function downloadAndResize(opts: DownloadAndResizeOpts) { const controller = new AbortController() const to = setTimeout(() => controller.abort(), opts.timeout || 5e3) @@ -27,58 +53,7 @@ export async function downloadAndResize(opts: DownloadAndResizeOpts) { clearTimeout(to) const dataUri = await blobToDataUri(resBody) - return await resize(dataUri, opts) -} - -export interface ResizeOpts { - width: number - height: number - mode: 'contain' | 'cover' | 'stretch' - maxSize: number -} - -export async function resize( - dataUri: string, - _opts: ResizeOpts, -): Promise { - const dim = await getImageDim(dataUri) - // TODO -- need to resize - return { - path: dataUri, - mime: extractDataUriMime(dataUri), - size: getDataUriSize(dataUri), - width: dim.width, - height: dim.height, - } -} - -export async function compressIfNeeded( - img: Image, - maxSize: number, -): Promise { - if (img.size > maxSize) { - // TODO - throw new Error( - "This image is too large and we haven't implemented compression yet -- sorry!", - ) - } - return img -} - -export interface Dim { - width: number - height: number -} -export function scaleDownDimensions(dim: Dim, max: Dim): Dim { - if (dim.width < max.width && dim.height < max.height) { - return dim - } - let wScale = dim.width > max.width ? max.width / dim.width : 1 - let hScale = dim.height > max.height ? max.height / dim.height : 1 - if (wScale < hScale) { - return {width: dim.width * wScale, height: dim.height * wScale} - } - return {width: dim.width * hScale, height: dim.height * hScale} + return await doResize(dataUri, opts) } export async function saveImageModal(_opts: {uri: string}) { @@ -86,11 +61,7 @@ export async function saveImageModal(_opts: {uri: string}) { throw new Error('TODO') } -export async function moveToPremanantPath(path: string) { - return path -} - -export async function getImageDim(path: string): Promise { +export async function getImageDim(path: string): Promise { var img = document.createElement('img') const promise = new Promise((resolve, reject) => { img.onload = resolve @@ -101,17 +72,82 @@ export async function getImageDim(path: string): Promise { return {width: img.width, height: img.height} } -function blobToDataUri(blob: Blob): Promise { - return new Promise((resolve, reject) => { - const reader = new FileReader() - reader.onloadend = () => { - if (typeof reader.result === 'string') { - resolve(reader.result) - } else { - reject(new Error('Failed to read blob')) - } +// internal methods +// = + +interface DoResizeOpts { + width: number + height: number + mode: 'contain' | 'cover' | 'stretch' + maxSize: number +} + +async function doResize(dataUri: string, opts: DoResizeOpts): Promise { + let newDataUri + + for (let i = 0; i <= 10; i++) { + newDataUri = await createResizedImage(dataUri, { + width: opts.width, + height: opts.height, + quality: 1 - i * 0.1, + mode: opts.mode, + }) + if (getDataUriSize(newDataUri) < opts.maxSize) { + break } - reader.onerror = reject - reader.readAsDataURL(blob) + } + if (!newDataUri) { + throw new Error('Failed to compress image') + } + return { + path: newDataUri, + mime: 'image/jpeg', + size: getDataUriSize(newDataUri), + width: opts.width, + height: opts.height, + } +} + +function createResizedImage( + dataUri: string, + { + width, + height, + quality, + mode, + }: { + width: number + height: number + quality: number + mode: 'contain' | 'cover' | 'stretch' + }, +): Promise { + return new Promise((resolve, reject) => { + const img = document.createElement('img') + img.addEventListener('load', () => { + const canvas = document.createElement('canvas') + const ctx = canvas.getContext('2d') + if (!ctx) { + return reject(new Error('Failed to resize image')) + } + + canvas.width = width + canvas.height = height + + let scale = 1 + if (mode === 'cover') { + scale = img.width < img.height ? width / img.width : height / img.height + } else if (mode === 'contain') { + scale = img.width > img.height ? width / img.width : height / img.height + } + let w = img.width * scale + let h = img.height * scale + let x = (width - w) / 2 + let y = (height - h) / 2 + + ctx.drawImage(img, x, y, w, h) + resolve(canvas.toDataURL('image/jpeg', quality)) + }) + img.src = dataUri }) } diff --git a/src/lib/media/picker.e2e.tsx b/src/lib/media/picker.e2e.tsx index 9f4765ac..e53dc42b 100644 --- a/src/lib/media/picker.e2e.tsx +++ b/src/lib/media/picker.e2e.tsx @@ -1,13 +1,8 @@ import {RootStoreModel} from 'state/index' -import {PickerOpts, CameraOpts, CropperOpts, PickedMedia} from './types' -import { - scaleDownDimensions, - Dim, - compressIfNeeded, - moveToPremanantPath, -} from 'lib/media/manip' -export type {PickedMedia} from './types' +import {Image as RNImage} from 'react-native-image-crop-picker' import RNFS from 'react-native-fs' +import {CropperOptions} from './types' +import {compressAndResizeImageForPost} from './manip' let _imageCounter = 0 async function getFile() { @@ -17,100 +12,33 @@ async function getFile() { .concat(['Media', 'DCIM', '100APPLE']) .join('/'), ) - return files[_imageCounter++ % files.length] -} - -export async function openPicker( - _store: RootStoreModel, - opts: PickerOpts, -): Promise { - const mediaType = opts.mediaType || 'photo' - const items = await getFile() - const toMedia = (item: RNFS.ReadDirItem) => ({ - mediaType, - path: item.path, + const file = files[_imageCounter++ % files.length] + return await compressAndResizeImageForPost({ + path: file.path, mime: 'image/jpeg', - size: item.size, + size: file.size, width: 4288, height: 2848, }) - if (Array.isArray(items)) { - return items.map(toMedia) - } - return [toMedia(items)] } -export async function openCamera( - _store: RootStoreModel, - opts: CameraOpts, -): Promise { - const mediaType = opts.mediaType || 'photo' - const item = await getFile() - return { - mediaType, - path: item.path, - mime: 'image/jpeg', - size: item.size, - width: 4288, - height: 2848, - } +export async function openPicker(_store: RootStoreModel): Promise { + return [await getFile()] +} + +export async function openCamera(_store: RootStoreModel): Promise { + return await getFile() } export async function openCropper( _store: RootStoreModel, - opts: CropperOpts, -): Promise { - const mediaType = opts.mediaType || 'photo' - const item = await getFile() + opts: CropperOptions, +): Promise { return { - mediaType, - path: item.path, + path: opts.path, mime: 'image/jpeg', - size: item.size, + size: 123, width: 4288, height: 2848, } } - -export async function pickImagesFlow( - store: RootStoreModel, - maxFiles: number, - maxDim: Dim, - maxSize: number, -) { - const items = await openPicker(store, { - multiple: true, - maxFiles, - mediaType: 'photo', - }) - const result = [] - for (const image of items) { - result.push( - await cropAndCompressFlow(store, image.path, image, maxDim, maxSize), - ) - } - return result -} - -export async function cropAndCompressFlow( - store: RootStoreModel, - path: string, - imgDim: Dim, - maxDim: Dim, - maxSize: number, -) { - // choose target dimensions based on the original - // this causes the photo cropper to start with the full image "selected" - const {width, height} = scaleDownDimensions(imgDim, maxDim) - const cropperRes = await openCropper(store, { - mediaType: 'photo', - path, - freeStyleCropEnabled: true, - width, - height, - }) - - const img = await compressIfNeeded(cropperRes, maxSize) - const permanentPath = await moveToPremanantPath(img.path) - return permanentPath -} diff --git a/src/lib/media/picker.tsx b/src/lib/media/picker.tsx index 70a5d906..af4a3e4d 100644 --- a/src/lib/media/picker.tsx +++ b/src/lib/media/picker.tsx @@ -5,14 +5,8 @@ import { ImageOrVideo, } from 'react-native-image-crop-picker' import {RootStoreModel} from 'state/index' -import {PickerOpts, CameraOpts, CropperOpts, PickedMedia} from './types' -import { - scaleDownDimensions, - Dim, - compressIfNeeded, - moveToPremanantPath, -} from 'lib/media/manip' -export type {PickedMedia} from './types' +import {PickerOpts, CameraOpts, CropperOptions} from './types' +import {Image as RNImage} from 'react-native-image-crop-picker' /** * NOTE @@ -25,18 +19,17 @@ export type {PickedMedia} from './types' export async function openPicker( _store: RootStoreModel, - opts: PickerOpts, -): Promise { - const mediaType = opts.mediaType || 'photo' + opts?: PickerOpts, +): Promise { const items = await openPickerFn({ - mediaType, - multiple: opts.multiple, - maxFiles: opts.maxFiles, + mediaType: 'photo', // TODO: eventually add other media types + multiple: opts?.multiple, + maxFiles: opts?.maxFiles, forceJpg: true, // ios only compressImageQuality: 0.8, }) + const toMedia = (item: ImageOrVideo) => ({ - mediaType, path: item.path, mime: item.mime, size: item.size, @@ -52,20 +45,17 @@ export async function openPicker( export async function openCamera( _store: RootStoreModel, opts: CameraOpts, -): Promise { - const mediaType = opts.mediaType || 'photo' +): Promise { const item = await openCameraFn({ - mediaType, width: opts.width, height: opts.height, freeStyleCropEnabled: opts.freeStyleCropEnabled, cropperCircleOverlay: opts.cropperCircleOverlay, - cropping: true, + cropping: false, forceJpg: true, // ios only compressImageQuality: 0.8, }) return { - mediaType, path: item.path, mime: item.mime, size: item.size, @@ -76,21 +66,15 @@ export async function openCamera( export async function openCropper( _store: RootStoreModel, - opts: CropperOpts, -): Promise { - const mediaType = opts.mediaType || 'photo' + opts: CropperOptions, +): Promise { const item = await openCropperFn({ - path: opts.path, - mediaType: opts.mediaType || 'photo', - width: opts.width, - height: opts.height, - freeStyleCropEnabled: opts.freeStyleCropEnabled, - cropperCircleOverlay: opts.cropperCircleOverlay, + ...opts, forceJpg: true, // ios only compressImageQuality: 0.8, }) + return { - mediaType, path: item.path, mime: item.mime, size: item.size, @@ -98,46 +82,3 @@ export async function openCropper( height: item.height, } } - -export async function pickImagesFlow( - store: RootStoreModel, - maxFiles: number, - maxDim: Dim, - maxSize: number, -) { - const items = await openPicker(store, { - multiple: true, - maxFiles, - mediaType: 'photo', - }) - const result = [] - for (const image of items) { - result.push( - await cropAndCompressFlow(store, image.path, image, maxDim, maxSize), - ) - } - return result -} - -export async function cropAndCompressFlow( - store: RootStoreModel, - path: string, - imgDim: Dim, - maxDim: Dim, - maxSize: number, -) { - // choose target dimensions based on the original - // this causes the photo cropper to start with the full image "selected" - const {width, height} = scaleDownDimensions(imgDim, maxDim) - const cropperRes = await openCropper(store, { - mediaType: 'photo', - path, - freeStyleCropEnabled: true, - width, - height, - }) - - const img = await compressIfNeeded(cropperRes, maxSize) - const permanentPath = await moveToPremanantPath(img.path) - return permanentPath -} diff --git a/src/lib/media/picker.web.tsx b/src/lib/media/picker.web.tsx index 158c3797..3a986998 100644 --- a/src/lib/media/picker.web.tsx +++ b/src/lib/media/picker.web.tsx @@ -1,16 +1,10 @@ /// -import {PickerOpts, CameraOpts, CropperOpts, PickedMedia} from './types' -export type {PickedMedia} from './types' +import {PickerOpts, CameraOpts, CropperOptions} from './types' import {RootStoreModel} from 'state/index' -import { - scaleDownDimensions, - getImageDim, - Dim, - compressIfNeeded, - moveToPremanantPath, -} from 'lib/media/manip' +import {getImageDim} from 'lib/media/manip' import {extractDataUriMime} from './util' +import {Image as RNImage} from 'react-native-image-crop-picker' interface PickedFile { uri: string @@ -21,13 +15,12 @@ interface PickedFile { export async function openPicker( _store: RootStoreModel, opts: PickerOpts, -): Promise { +): Promise { const res = await selectFile(opts) const dim = await getImageDim(res.uri) const mime = extractDataUriMime(res.uri) return [ { - mediaType: 'photo', path: res.uri, mime, size: res.size, @@ -40,21 +33,21 @@ export async function openPicker( export async function openCamera( _store: RootStoreModel, _opts: CameraOpts, -): Promise { +): Promise { // const mediaType = opts.mediaType || 'photo' TODO throw new Error('TODO') } export async function openCropper( store: RootStoreModel, - opts: CropperOpts, -): Promise { + opts: CropperOptions, +): Promise { // TODO handle more opts return new Promise((resolve, reject) => { store.shell.openModal({ name: 'crop-image', uri: opts.path, - onSelect: (img?: PickedMedia) => { + onSelect: (img?: RNImage) => { if (img) { resolve(img) } else { @@ -65,52 +58,6 @@ export async function openCropper( }) } -export async function pickImagesFlow( - store: RootStoreModel, - maxFiles: number, - maxDim: Dim, - maxSize: number, -) { - const items = await openPicker(store, { - multiple: true, - maxFiles, - mediaType: 'photo', - }) - const result = [] - for (const image of items) { - result.push( - await cropAndCompressFlow(store, image.path, image, maxDim, maxSize), - ) - } - return result -} - -export async function cropAndCompressFlow( - store: RootStoreModel, - path: string, - imgDim: Dim, - maxDim: Dim, - maxSize: number, -) { - // choose target dimensions based on the original - // this causes the photo cropper to start with the full image "selected" - const {width, height} = scaleDownDimensions(imgDim, maxDim) - const cropperRes = await openCropper(store, { - mediaType: 'photo', - path, - freeStyleCropEnabled: true, - width, - height, - }) - - const img = await compressIfNeeded(cropperRes, maxSize) - const permanentPath = await moveToPremanantPath(img.path) - return permanentPath -} - -// helpers -// = - /** * Opens the select file dialog in the browser. * NOTE: diff --git a/src/lib/media/types.ts b/src/lib/media/types.ts index 3197b4d3..e6f44275 100644 --- a/src/lib/media/types.ts +++ b/src/lib/media/types.ts @@ -1,31 +1,21 @@ +import {openCropper} from 'react-native-image-crop-picker' + +export interface Dimensions { + width: number + height: number +} + export interface PickerOpts { - mediaType?: 'photo' + mediaType?: string multiple?: boolean maxFiles?: number } export interface CameraOpts { - mediaType?: 'photo' width: number height: number freeStyleCropEnabled?: boolean cropperCircleOverlay?: boolean } -export interface CropperOpts { - path: string - mediaType?: 'photo' - width: number - height: number - freeStyleCropEnabled?: boolean - cropperCircleOverlay?: boolean -} - -export interface PickedMedia { - mediaType: 'photo' - path: string - mime: string - size: number - width: number - height: number -} +export type CropperOptions = Parameters[0] diff --git a/src/lib/media/util.ts b/src/lib/media/util.ts index a27c71d8..75915de6 100644 --- a/src/lib/media/util.ts +++ b/src/lib/media/util.ts @@ -1,7 +1,45 @@ +import {Dimensions} from './types' + export function extractDataUriMime(uri: string): string { return uri.substring(uri.indexOf(':') + 1, uri.indexOf(';')) } +// Fairly accurate estimate that is more performant +// than decoding and checking length of URI export function getDataUriSize(uri: string): number { - return Math.round((uri.length * 3) / 4) // very rough estimate + return Math.round((uri.length * 3) / 4) +} + +export function scaleDownDimensions( + dim: Dimensions, + max: Dimensions, +): Dimensions { + if (dim.width < max.width && dim.height < max.height) { + return dim + } + const wScale = dim.width > max.width ? max.width / dim.width : 1 + const hScale = dim.height > max.height ? max.height / dim.height : 1 + if (wScale < hScale) { + return {width: dim.width * wScale, height: dim.height * wScale} + } + return {width: dim.width * hScale, height: dim.height * hScale} +} + +export function isUriImage(uri: string) { + return /\.(jpg|jpeg|png).*$/.test(uri) +} + +export function blobToDataUri(blob: Blob): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onloadend = () => { + if (typeof reader.result === 'string') { + resolve(reader.result) + } else { + reject(new Error('Failed to read blob')) + } + } + reader.onerror = reject + reader.readAsDataURL(blob) + }) } diff --git a/src/state/models/cache/image-sizes.ts b/src/state/models/cache/image-sizes.ts index 2fd6e001..bbfb9612 100644 --- a/src/state/models/cache/image-sizes.ts +++ b/src/state/models/cache/image-sizes.ts @@ -1,24 +1,24 @@ import {Image} from 'react-native' -import {Dim} from 'lib/media/manip' +import type {Dimensions} from 'lib/media/types' export class ImageSizesCache { - sizes: Map = new Map() - activeRequests: Map> = new Map() + sizes: Map = new Map() + activeRequests: Map> = new Map() constructor() {} - get(uri: string): Dim | undefined { + get(uri: string): Dimensions | undefined { return this.sizes.get(uri) } - async fetch(uri: string): Promise { - const dim = this.sizes.get(uri) - if (dim) { - return dim + async fetch(uri: string): Promise { + const Dimensions = this.sizes.get(uri) + if (Dimensions) { + return Dimensions } const prom = this.activeRequests.get(uri) || - new Promise(resolve => { + new Promise(resolve => { Image.getSize( uri, (width: number, height: number) => resolve({width, height}), diff --git a/src/state/models/content/profile.ts b/src/state/models/content/profile.ts index 45d928c9..c26dc874 100644 --- a/src/state/models/content/profile.ts +++ b/src/state/models/content/profile.ts @@ -1,5 +1,4 @@ import {makeAutoObservable, runInAction} from 'mobx' -import {PickedMedia} from 'lib/media/picker' import { ComAtprotoLabelDefs, AppBskyActorGetProfile as GetProfile, @@ -10,6 +9,7 @@ import {RootStoreModel} from '../root-store' import * as apilib from 'lib/api/index' import {cleanError} from 'lib/strings/errors' import {FollowState} from '../cache/my-follows' +import {Image as RNImage} from 'react-native-image-crop-picker' export const ACTOR_TYPE_USER = 'app.bsky.system.actorUser' @@ -122,8 +122,8 @@ export class ProfileModel { async updateProfile( updates: AppBskyActorProfile.Record, - newUserAvatar: PickedMedia | undefined | null, - newUserBanner: PickedMedia | undefined | null, + newUserAvatar: RNImage | undefined | null, + newUserBanner: RNImage | undefined | null, ) { await this.rootStore.agent.upsertProfile(async existing => { existing = existing || {} diff --git a/src/state/models/media/gallery.ts b/src/state/models/media/gallery.ts new file mode 100644 index 00000000..fbe6c92a --- /dev/null +++ b/src/state/models/media/gallery.ts @@ -0,0 +1,85 @@ +import {makeAutoObservable, runInAction} from 'mobx' +import {RootStoreModel} from 'state/index' +import {ImageModel} from './image' +import {Image as RNImage} from 'react-native-image-crop-picker' +import {openPicker} from 'lib/media/picker' +import {getImageDim} from 'lib/media/manip' +import {getDataUriSize} from 'lib/media/util' + +export class GalleryModel { + images: ImageModel[] = [] + + constructor(public rootStore: RootStoreModel) { + makeAutoObservable(this, { + rootStore: false, + }) + } + + get isEmpty() { + return this.size === 0 + } + + get size() { + return this.images.length + } + + get paths() { + return this.images.map(image => + image.compressed === undefined ? image.path : image.compressed.path, + ) + } + + async add(image_: RNImage) { + if (this.size >= 4) { + return + } + + // Temporarily enforce uniqueness but can eventually also use index + if (!this.images.some(i => i.path === image_.path)) { + const image = new ImageModel(this.rootStore, image_) + await image.compress() + + runInAction(() => { + this.images.push(image) + }) + } + } + + async paste(uri: string) { + if (this.size >= 4) { + return + } + + const {width, height} = await getImageDim(uri) + + const image: RNImage = { + path: uri, + height, + width, + size: getDataUriSize(uri), + mime: 'image/jpeg', + } + + runInAction(() => { + this.add(image) + }) + } + + crop(image: ImageModel) { + image.crop() + } + + remove(image: ImageModel) { + const index = this.images.findIndex(image_ => image_.path === image.path) + this.images.splice(index, 1) + } + + async pick() { + const images = await openPicker(this.rootStore, { + multiple: true, + maxFiles: 4 - this.images.length, + }) + + await Promise.all(images.map(image => this.add(image))) + } +} diff --git a/src/state/models/media/image.ts b/src/state/models/media/image.ts new file mode 100644 index 00000000..584bf90c --- /dev/null +++ b/src/state/models/media/image.ts @@ -0,0 +1,85 @@ +import {Image as RNImage} from 'react-native-image-crop-picker' +import {RootStoreModel} from 'state/index' +import {compressAndResizeImageForPost} from 'lib/media/manip' +import {makeAutoObservable, runInAction} from 'mobx' +import {openCropper} from 'lib/media/picker' +import {POST_IMG_MAX} from 'lib/constants' +import {scaleDownDimensions} from 'lib/media/util' + +// TODO: EXIF embed +// Cases to consider: ExternalEmbed +export class ImageModel implements RNImage { + path: string + mime = 'image/jpeg' + width: number + height: number + size: number + cropped?: RNImage = undefined + compressed?: RNImage = undefined + scaledWidth: number = POST_IMG_MAX.width + scaledHeight: number = POST_IMG_MAX.height + + constructor(public rootStore: RootStoreModel, image: RNImage) { + makeAutoObservable(this, { + rootStore: false, + }) + + this.path = image.path + this.width = image.width + this.height = image.height + this.size = image.size + this.calcScaledDimensions() + } + + calcScaledDimensions() { + const {width, height} = scaleDownDimensions( + {width: this.width, height: this.height}, + POST_IMG_MAX, + ) + + this.scaledWidth = width + this.scaledHeight = height + } + + async crop() { + try { + const cropped = await openCropper(this.rootStore, { + mediaType: 'photo', + path: this.path, + freeStyleCropEnabled: true, + width: this.scaledWidth, + height: this.scaledHeight, + }) + + runInAction(() => { + this.cropped = cropped + }) + } catch (err) { + this.rootStore.log.error('Failed to crop photo', err) + } + + this.compress() + } + + async compress() { + try { + const {width, height} = scaleDownDimensions( + this.cropped + ? {width: this.cropped.width, height: this.cropped.height} + : {width: this.width, height: this.height}, + POST_IMG_MAX, + ) + const compressed = await compressAndResizeImageForPost({ + ...(this.cropped === undefined ? this : this.cropped), + width, + height, + }) + + runInAction(() => { + this.compressed = compressed + }) + } catch (err) { + this.rootStore.log.error('Failed to compress photo', err) + } + } +} diff --git a/src/state/models/ui/shell.ts b/src/state/models/ui/shell.ts index dd5c899b..47cc0aa8 100644 --- a/src/state/models/ui/shell.ts +++ b/src/state/models/ui/shell.ts @@ -3,7 +3,7 @@ import {RootStoreModel} from '../root-store' import {makeAutoObservable} from 'mobx' import {ProfileModel} from '../content/profile' import {isObj, hasProp} from 'lib/type-guards' -import {PickedMedia} from 'lib/media/types' +import {Image} from 'lib/media/types' export interface ConfirmModal { name: 'confirm' @@ -38,7 +38,7 @@ export interface ReportAccountModal { export interface CropImageModal { name: 'crop-image' uri: string - onSelect: (img?: PickedMedia) => void + onSelect: (img?: Image) => void } export interface DeleteAccountModal { diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 7d72899f..f77005b5 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react' import {observer} from 'mobx-react-lite' import { ActivityIndicator, @@ -30,47 +30,42 @@ import {sanitizeDisplayName} from 'lib/strings/display-names' import {cleanError} from 'lib/strings/errors' import {SelectPhotoBtn} from './photos/SelectPhotoBtn' import {OpenCameraBtn} from './photos/OpenCameraBtn' -import {SelectedPhotos} from './photos/SelectedPhotos' import {usePalette} from 'lib/hooks/usePalette' import QuoteEmbed from '../util/post-embeds/QuoteEmbed' import {useExternalLinkFetch} from './useExternalLinkFetch' import {isDesktopWeb} from 'platform/detection' +import {GalleryModel} from 'state/models/media/gallery' +import {Gallery} from './photos/Gallery' const MAX_GRAPHEME_LENGTH = 300 +type Props = ComposerOpts & { + onClose: () => void +} + export const ComposePost = observer(function ComposePost({ replyTo, onPost, onClose, quote: initQuote, -}: { - replyTo?: ComposerOpts['replyTo'] - onPost?: ComposerOpts['onPost'] - onClose: () => void - quote?: ComposerOpts['quote'] -}) { +}: Props) { const {track} = useAnalytics() const pal = usePalette('default') const store = useStores() - const textInput = React.useRef(null) - const [isProcessing, setIsProcessing] = React.useState(false) - const [processingState, setProcessingState] = React.useState('') - const [error, setError] = React.useState('') - const [richtext, setRichText] = React.useState(new RichText({text: ''})) - const graphemeLength = React.useMemo( - () => richtext.graphemeLength, - [richtext], - ) - const [quote, setQuote] = React.useState( + const textInput = useRef(null) + const [isProcessing, setIsProcessing] = useState(false) + const [processingState, setProcessingState] = useState('') + const [error, setError] = useState('') + const [richtext, setRichText] = useState(new RichText({text: ''})) + const graphemeLength = useMemo(() => richtext.graphemeLength, [richtext]) + const [quote, setQuote] = useState( initQuote, ) const {extLink, setExtLink} = useExternalLinkFetch({setQuote}) - const [suggestedLinks, setSuggestedLinks] = React.useState>( - new Set(), - ) - const [selectedPhotos, setSelectedPhotos] = React.useState([]) + const [suggestedLinks, setSuggestedLinks] = useState>(new Set()) + const gallery = useMemo(() => new GalleryModel(store), [store]) - const autocompleteView = React.useMemo( + const autocompleteView = useMemo( () => new UserAutocompleteModel(store), [store], ) @@ -82,17 +77,17 @@ export const ComposePost = observer(function ComposePost({ // is focused during unmount, an exception will throw (seems that a blur method isnt implemented) // manually blurring before closing gets around that // -prf - const hackfixOnClose = React.useCallback(() => { + const hackfixOnClose = useCallback(() => { textInput.current?.blur() onClose() }, [textInput, onClose]) // initial setup - React.useEffect(() => { + useEffect(() => { autocompleteView.setup() }, [autocompleteView]) - React.useEffect(() => { + useEffect(() => { // HACK // wait a moment before focusing the input to resolve some layout bugs with the keyboard-avoiding-view // -prf @@ -109,60 +104,51 @@ export const ComposePost = observer(function ComposePost({ } }, []) - const onPressContainer = React.useCallback(() => { + const onPressContainer = useCallback(() => { textInput.current?.focus() }, [textInput]) - const onSelectPhotos = React.useCallback( - (photos: string[]) => { - track('Composer:SelectedPhotos') - setSelectedPhotos(photos) - }, - [track, setSelectedPhotos], - ) - - const onPressAddLinkCard = React.useCallback( + const onPressAddLinkCard = useCallback( (uri: string) => { setExtLink({uri, isLoading: true}) }, [setExtLink], ) - const onPhotoPasted = React.useCallback( + const onPhotoPasted = useCallback( async (uri: string) => { - if (selectedPhotos.length >= 4) { - return - } - onSelectPhotos([...selectedPhotos, uri]) + track('Composer:PastedPhotos') + gallery.paste(uri) }, - [selectedPhotos, onSelectPhotos], + [gallery, track], ) - const onPressPublish = React.useCallback(async () => { - if (isProcessing) { - return - } - if (richtext.graphemeLength > MAX_GRAPHEME_LENGTH) { + const onPressPublish = useCallback(async () => { + if (isProcessing || richtext.graphemeLength > MAX_GRAPHEME_LENGTH) { return } + setError('') - if (richtext.text.trim().length === 0 && selectedPhotos.length === 0) { + + if (richtext.text.trim().length === 0 && gallery.isEmpty) { setError('Did you want to say anything?') return false } + setIsProcessing(true) + try { await apilib.post(store, { rawText: richtext.text, replyTo: replyTo?.uri, - images: selectedPhotos, + images: gallery.paths, quote: quote, extLink: extLink, onStateChange: setProcessingState, knownHandles: autocompleteView.knownHandles, }) track('Create Post', { - imageCount: selectedPhotos.length, + imageCount: gallery.size, }) } catch (e: any) { if (extLink) { @@ -191,34 +177,33 @@ export const ComposePost = observer(function ComposePost({ hackfixOnClose, onPost, quote, - selectedPhotos, setExtLink, store, track, + gallery, ]) const canPost = graphemeLength <= MAX_GRAPHEME_LENGTH const selectTextInputPlaceholder = replyTo ? 'Write your reply' - : selectedPhotos.length !== 0 + : gallery.isEmpty ? 'Write a comment' : "What's up?" + const canSelectImages = gallery.size <= 4 + const viewStyles = { + paddingBottom: Platform.OS === 'android' ? insets.bottom : 0, + paddingTop: Platform.OS === 'android' ? insets.top : 15, + } + return ( - + - - {selectedPhotos.length === 0 && extLink && ( + + {gallery.isEmpty && extLink && ( setExtLink(undefined)} @@ -317,9 +299,7 @@ export const ComposePost = observer(function ComposePost({ ) : undefined} - {!extLink && - selectedPhotos.length === 0 && - suggestedLinks.size > 0 ? ( + {!extLink && suggestedLinks.size > 0 ? ( {Array.from(suggestedLinks).map(url => ( ) : null} - - + {canSelectImages ? ( + <> + + + + ) : null} diff --git a/src/view/com/composer/ExternalEmbed.tsx b/src/view/com/composer/ExternalEmbed.tsx index 65802333..b6a45f6a 100644 --- a/src/view/com/composer/ExternalEmbed.tsx +++ b/src/view/com/composer/ExternalEmbed.tsx @@ -2,11 +2,10 @@ import React from 'react' import { ActivityIndicator, StyleSheet, - TouchableWithoutFeedback, + TouchableOpacity, View, } from 'react-native' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {BlurView} from '../util/BlurView' import {AutoSizedImage} from '../util/images/AutoSizedImage' import {Text} from '../util/text/Text' import {s} from 'lib/styles' @@ -61,11 +60,9 @@ export const ExternalEmbed = ({ )} - - - - - + + + ) } @@ -92,6 +89,7 @@ const styles = StyleSheet.create({ right: 10, width: 36, height: 36, + backgroundColor: 'rgba(0, 0, 0, 0.75)', borderRadius: 18, alignItems: 'center', justifyContent: 'center', diff --git a/src/view/com/composer/photos/Gallery.tsx b/src/view/com/composer/photos/Gallery.tsx new file mode 100644 index 00000000..f4dfc88f --- /dev/null +++ b/src/view/com/composer/photos/Gallery.tsx @@ -0,0 +1,130 @@ +import React, {useCallback} from 'react' +import {GalleryModel} from 'state/models/media/gallery' +import {observer} from 'mobx-react-lite' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {colors} from 'lib/styles' +import {StyleSheet, TouchableOpacity, View} from 'react-native' +import {ImageModel} from 'state/models/media/image' +import {Image} from 'expo-image' + +interface Props { + gallery: GalleryModel +} + +export const Gallery = observer(function ({gallery}: Props) { + const getImageStyle = useCallback(() => { + switch (gallery.size) { + case 1: + return styles.image250 + case 2: + return styles.image175 + default: + return styles.image85 + } + }, [gallery]) + + const imageStyle = getImageStyle() + const handleRemovePhoto = useCallback( + (image: ImageModel) => { + gallery.remove(image) + }, + [gallery], + ) + + const handleEditPhoto = useCallback( + (image: ImageModel) => { + gallery.crop(image) + }, + [gallery], + ) + + return !gallery.isEmpty ? ( + + {gallery.images.map(image => + image.compressed !== undefined ? ( + + + { + handleEditPhoto(image) + }} + style={styles.imageControl}> + + + handleRemovePhoto(image)} + style={styles.imageControl}> + + + + + + + ) : null, + )} + + ) : null +}) + +const styles = StyleSheet.create({ + gallery: { + flex: 1, + flexDirection: 'row', + marginTop: 16, + }, + imageContainer: { + margin: 2, + }, + image: { + resizeMode: 'cover', + borderRadius: 8, + }, + image250: { + width: 250, + height: 250, + }, + image175: { + width: 175, + height: 175, + }, + image85: { + width: 85, + height: 85, + }, + imageControls: { + position: 'absolute', + display: 'flex', + flexDirection: 'row', + gap: 4, + top: 8, + right: 8, + zIndex: 1, + }, + imageControl: { + width: 24, + height: 24, + borderRadius: 12, + backgroundColor: 'rgba(0, 0, 0, 0.75)', + borderWidth: 0.5, + alignItems: 'center', + justifyContent: 'center', + }, +}) diff --git a/src/view/com/composer/photos/OpenCameraBtn.tsx b/src/view/com/composer/photos/OpenCameraBtn.tsx index 11872878..809c4178 100644 --- a/src/view/com/composer/photos/OpenCameraBtn.tsx +++ b/src/view/com/composer/photos/OpenCameraBtn.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import React, {useCallback} from 'react' import {TouchableOpacity} from 'react-native' import { FontAwesomeIcon, @@ -10,62 +10,44 @@ import {useStores} from 'state/index' import {s} from 'lib/styles' import {isDesktopWeb} from 'platform/detection' import {openCamera} from 'lib/media/picker' -import {compressIfNeeded} from 'lib/media/manip' import {useCameraPermission} from 'lib/hooks/usePermissions' -import { - POST_IMG_MAX_WIDTH, - POST_IMG_MAX_HEIGHT, - POST_IMG_MAX_SIZE, -} from 'lib/constants' +import {POST_IMG_MAX} from 'lib/constants' +import {GalleryModel} from 'state/models/media/gallery' const HITSLOP = {left: 10, top: 10, right: 10, bottom: 10} -export function OpenCameraBtn({ - enabled, - selectedPhotos, - onSelectPhotos, -}: { - enabled: boolean - selectedPhotos: string[] - onSelectPhotos: (v: string[]) => void -}) { +type Props = { + gallery: GalleryModel +} + +export function OpenCameraBtn({gallery}: Props) { const pal = usePalette('default') const {track} = useAnalytics() const store = useStores() const {requestCameraAccessIfNeeded} = useCameraPermission() - const onPressTakePicture = React.useCallback(async () => { + const onPressTakePicture = useCallback(async () => { track('Composer:CameraOpened') - if (!enabled) { - return - } try { if (!(await requestCameraAccessIfNeeded())) { return } - const cameraRes = await openCamera(store, { - mediaType: 'photo', - width: POST_IMG_MAX_WIDTH, - height: POST_IMG_MAX_HEIGHT, + + const img = await openCamera(store, { + width: POST_IMG_MAX.width, + height: POST_IMG_MAX.height, freeStyleCropEnabled: true, }) - const img = await compressIfNeeded(cameraRes, POST_IMG_MAX_SIZE) - onSelectPhotos([...selectedPhotos, img.path]) + + gallery.add(img) } catch (err: any) { // ignore store.log.warn('Error using camera', err) } - }, [ - track, - store, - onSelectPhotos, - selectedPhotos, - enabled, - requestCameraAccessIfNeeded, - ]) + }, [gallery, track, store, requestCameraAccessIfNeeded]) if (isDesktopWeb) { - return <> + return null } return ( @@ -76,11 +58,7 @@ export function OpenCameraBtn({ hitSlop={HITSLOP}> diff --git a/src/view/com/composer/photos/OpenCameraBtn.web.tsx b/src/view/com/composer/photos/OpenCameraBtn.web.tsx new file mode 100644 index 00000000..226de1f6 --- /dev/null +++ b/src/view/com/composer/photos/OpenCameraBtn.web.tsx @@ -0,0 +1,3 @@ +export function OpenCameraBtn() { + return null +} diff --git a/src/view/com/composer/photos/SelectPhotoBtn.tsx b/src/view/com/composer/photos/SelectPhotoBtn.tsx index c0808b85..9569e08a 100644 --- a/src/view/com/composer/photos/SelectPhotoBtn.tsx +++ b/src/view/com/composer/photos/SelectPhotoBtn.tsx @@ -1,86 +1,36 @@ -import React from 'react' -import {Platform, TouchableOpacity} from 'react-native' +import React, {useCallback} from 'react' +import {TouchableOpacity} from 'react-native' import { FontAwesomeIcon, FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' import {usePalette} from 'lib/hooks/usePalette' import {useAnalytics} from 'lib/analytics' -import {useStores} from 'state/index' import {s} from 'lib/styles' import {isDesktopWeb} from 'platform/detection' -import {openPicker, cropAndCompressFlow, pickImagesFlow} from 'lib/media/picker' import {usePhotoLibraryPermission} from 'lib/hooks/usePermissions' -import { - POST_IMG_MAX_WIDTH, - POST_IMG_MAX_HEIGHT, - POST_IMG_MAX_SIZE, -} from 'lib/constants' +import {GalleryModel} from 'state/models/media/gallery' const HITSLOP = {left: 10, top: 10, right: 10, bottom: 10} -export function SelectPhotoBtn({ - enabled, - selectedPhotos, - onSelectPhotos, -}: { - enabled: boolean - selectedPhotos: string[] - onSelectPhotos: (v: string[]) => void -}) { +type Props = { + gallery: GalleryModel +} + +export function SelectPhotoBtn({gallery}: Props) { const pal = usePalette('default') const {track} = useAnalytics() - const store = useStores() const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission() - const onPressSelectPhotos = React.useCallback(async () => { + const onPressSelectPhotos = useCallback(async () => { track('Composer:GalleryOpened') - if (!enabled) { + + if (!isDesktopWeb && !(await requestPhotoAccessIfNeeded())) { return } - if (isDesktopWeb) { - const images = await pickImagesFlow( - store, - 4 - selectedPhotos.length, - {width: POST_IMG_MAX_WIDTH, height: POST_IMG_MAX_HEIGHT}, - POST_IMG_MAX_SIZE, - ) - onSelectPhotos([...selectedPhotos, ...images]) - } else { - if (!(await requestPhotoAccessIfNeeded())) { - return - } - const items = await openPicker(store, { - multiple: true, - maxFiles: 4 - selectedPhotos.length, - mediaType: 'photo', - }) - const result = [] - for (const image of items) { - if (Platform.OS === 'android') { - result.push(image.path) - continue - } - result.push( - await cropAndCompressFlow( - store, - image.path, - image, - {width: POST_IMG_MAX_WIDTH, height: POST_IMG_MAX_HEIGHT}, - POST_IMG_MAX_SIZE, - ), - ) - } - onSelectPhotos([...selectedPhotos, ...result]) - } - }, [ - track, - store, - onSelectPhotos, - selectedPhotos, - enabled, - requestPhotoAccessIfNeeded, - ]) + + gallery.pick() + }, [track, gallery, requestPhotoAccessIfNeeded]) return ( diff --git a/src/view/com/composer/photos/SelectedPhotos.tsx b/src/view/com/composer/photos/SelectedPhotos.tsx deleted file mode 100644 index d22f5d8c..00000000 --- a/src/view/com/composer/photos/SelectedPhotos.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import React, {useCallback} from 'react' -import {StyleSheet, TouchableOpacity, View} from 'react-native' -import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {Image} from 'expo-image' -import {colors} from 'lib/styles' - -export const SelectedPhotos = ({ - selectedPhotos, - onSelectPhotos, -}: { - selectedPhotos: string[] - onSelectPhotos: (v: string[]) => void -}) => { - const imageStyle = - selectedPhotos.length === 1 - ? styles.image250 - : selectedPhotos.length === 2 - ? styles.image175 - : styles.image85 - - const handleRemovePhoto = useCallback( - item => { - onSelectPhotos(selectedPhotos.filter(filterItem => filterItem !== item)) - }, - [selectedPhotos, onSelectPhotos], - ) - - return selectedPhotos.length !== 0 ? ( - - {selectedPhotos.length !== 0 && - selectedPhotos.map((item, index) => ( - - handleRemovePhoto(item)} - style={styles.removePhotoButton}> - - - - - - ))} - - ) : null -} - -const styles = StyleSheet.create({ - gallery: { - flex: 1, - flexDirection: 'row', - marginTop: 16, - }, - imageContainer: { - margin: 2, - }, - image: { - resizeMode: 'cover', - borderRadius: 8, - }, - image250: { - width: 250, - height: 250, - }, - image175: { - width: 175, - height: 175, - }, - image85: { - width: 85, - height: 85, - }, - removePhotoButton: { - position: 'absolute', - top: 8, - right: 8, - width: 24, - height: 24, - borderRadius: 12, - alignItems: 'center', - justifyContent: 'center', - backgroundColor: colors.black, - zIndex: 1, - borderColor: colors.gray4, - borderWidth: 0.5, - }, -}) diff --git a/src/view/com/composer/text-input/TextInput.tsx b/src/view/com/composer/text-input/TextInput.tsx index bd536e1c..9c111bd3 100644 --- a/src/view/com/composer/text-input/TextInput.tsx +++ b/src/view/com/composer/text-input/TextInput.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import React, {forwardRef, useCallback, useEffect, useRef, useMemo} from 'react' import { NativeSyntheticEvent, StyleSheet, @@ -14,18 +14,13 @@ import isEqual from 'lodash.isequal' import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete' import {Autocomplete} from './mobile/Autocomplete' import {Text} from 'view/com/util/text/Text' -import {useStores} from 'state/index' import {cleanError} from 'lib/strings/errors' -import {getImageDim} from 'lib/media/manip' -import {cropAndCompressFlow} from 'lib/media/picker' import {getMentionAt, insertMentionAt} from 'lib/strings/mention-manip' -import { - POST_IMG_MAX_WIDTH, - POST_IMG_MAX_HEIGHT, - POST_IMG_MAX_SIZE, -} from 'lib/constants' import {usePalette} from 'lib/hooks/usePalette' import {useTheme} from 'lib/ThemeContext' +import {isUriImage} from 'lib/media/util' +import {downloadAndResize} from 'lib/media/manip' +import {POST_IMG_MAX} from 'lib/constants' export interface TextInputRef { focus: () => void @@ -48,7 +43,7 @@ interface Selection { end: number } -export const TextInput = React.forwardRef( +export const TextInput = forwardRef( ( { richtext, @@ -63,9 +58,8 @@ export const TextInput = React.forwardRef( ref, ) => { const pal = usePalette('default') - const store = useStores() - const textInput = React.useRef(null) - const textInputSelection = React.useRef({start: 0, end: 0}) + const textInput = useRef(null) + const textInputSelection = useRef({start: 0, end: 0}) const theme = useTheme() React.useImperativeHandle(ref, () => ({ @@ -73,7 +67,7 @@ export const TextInput = React.forwardRef( blur: () => textInput.current?.blur(), })) - React.useEffect(() => { + useEffect(() => { // HACK // wait a moment before focusing the input to resolve some layout bugs with the keyboard-avoiding-view // -prf @@ -90,8 +84,8 @@ export const TextInput = React.forwardRef( } }, []) - const onChangeText = React.useCallback( - (newText: string) => { + const onChangeText = useCallback( + async (newText: string) => { const newRt = new RichText({text: newText}) newRt.detectFacetsWithoutResolution() setRichText(newRt) @@ -108,50 +102,62 @@ export const TextInput = React.forwardRef( } const set: Set = new Set() + if (newRt.facets) { for (const facet of newRt.facets) { for (const feature of facet.features) { if (AppBskyRichtextFacet.isLink(feature)) { - set.add(feature.uri) + if (isUriImage(feature.uri)) { + const res = await downloadAndResize({ + uri: feature.uri, + width: POST_IMG_MAX.width, + height: POST_IMG_MAX.height, + mode: 'contain', + maxSize: POST_IMG_MAX.size, + timeout: 15e3, + }) + + if (res !== undefined) { + onPhotoPasted(res.path) + } + } else { + set.add(feature.uri) + } } } } } + if (!isEqual(set, suggestedLinks)) { onSuggestedLinksChanged(set) } }, - [setRichText, autocompleteView, suggestedLinks, onSuggestedLinksChanged], + [ + setRichText, + autocompleteView, + suggestedLinks, + onSuggestedLinksChanged, + onPhotoPasted, + ], ) - const onPaste = React.useCallback( + const onPaste = useCallback( async (err: string | undefined, files: PastedFile[]) => { if (err) { return onError(cleanError(err)) } + const uris = files.map(f => f.uri) - const imgUri = uris.find(uri => /\.(jpe?g|png)$/.test(uri)) - if (imgUri) { - let imgDim - try { - imgDim = await getImageDim(imgUri) - } catch (e) { - imgDim = {width: POST_IMG_MAX_WIDTH, height: POST_IMG_MAX_HEIGHT} - } - const finalImgPath = await cropAndCompressFlow( - store, - imgUri, - imgDim, - {width: POST_IMG_MAX_WIDTH, height: POST_IMG_MAX_HEIGHT}, - POST_IMG_MAX_SIZE, - ) - onPhotoPasted(finalImgPath) + const uri = uris.find(isUriImage) + + if (uri) { + onPhotoPasted(uri) } }, - [store, onError, onPhotoPasted], + [onError, onPhotoPasted], ) - const onSelectionChange = React.useCallback( + const onSelectionChange = useCallback( (evt: NativeSyntheticEvent) => { // NOTE we track the input selection using a ref to avoid excessive renders -prf textInputSelection.current = evt.nativeEvent.selection @@ -159,7 +165,7 @@ export const TextInput = React.forwardRef( [textInputSelection], ) - const onSelectAutocompleteItem = React.useCallback( + const onSelectAutocompleteItem = useCallback( (item: string) => { onChangeText( insertMentionAt( @@ -173,23 +179,19 @@ export const TextInput = React.forwardRef( [onChangeText, richtext, autocompleteView], ) - const textDecorated = React.useMemo(() => { + const textDecorated = useMemo(() => { let i = 0 - return Array.from(richtext.segments()).map(segment => { - if (!segment.facet) { - return ( - - {segment.text} - - ) - } else { - return ( - - {segment.text} - - ) - } - }) + + return Array.from(richtext.segments()).map(segment => ( + + {segment.text} + + )) }, [richtext, pal.link, pal.text]) return ( @@ -223,7 +225,6 @@ const styles = StyleSheet.create({ textInput: { flex: 1, width: '100%', - minHeight: 80, padding: 5, paddingBottom: 20, marginLeft: 8, diff --git a/src/view/com/composer/text-input/TextInput.web.tsx b/src/view/com/composer/text-input/TextInput.web.tsx index ba628a3f..e75da179 100644 --- a/src/view/com/composer/text-input/TextInput.web.tsx +++ b/src/view/com/composer/text-input/TextInput.web.tsx @@ -12,6 +12,7 @@ import isEqual from 'lodash.isequal' import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete' import {createSuggestion} from './web/Autocomplete' import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle' +import {isUriImage, blobToDataUri} from 'lib/media/util' export interface TextInputRef { focus: () => void @@ -37,7 +38,7 @@ export const TextInput = React.forwardRef( suggestedLinks, autocompleteView, setRichText, - // onPhotoPasted, TODO + onPhotoPasted, onSuggestedLinksChanged, }: // onError, TODO TextInputProps, @@ -72,6 +73,15 @@ export const TextInput = React.forwardRef( attributes: { class: modeClass, }, + handlePaste: (_, event) => { + const items = event.clipboardData?.items + + if (items === undefined) { + return + } + + getImageFromUri(items, onPhotoPasted) + }, }, content: richtext.text.toString(), autofocus: true, @@ -147,3 +157,33 @@ const styles = StyleSheet.create({ marginBottom: 10, }, }) + +function getImageFromUri( + items: DataTransferItemList, + callback: (uri: string) => void, +) { + for (let index = 0; index < items.length; index++) { + const item = items[index] + const {kind, type} = item + + if (type === 'text/plain') { + item.getAsString(async itemString => { + if (isUriImage(itemString)) { + const response = await fetch(itemString) + const blob = await response.blob() + blobToDataUri(blob).then(callback, err => console.error(err)) + } + }) + } + + if (kind === 'file') { + const file = item.getAsFile() + + if (file instanceof Blob) { + blobToDataUri(new Blob([file], {type: item.type})).then(callback, err => + console.error(err), + ) + } + } + } +} diff --git a/src/view/com/composer/useExternalLinkFetch.ts b/src/view/com/composer/useExternalLinkFetch.ts index 9cb91231..45c2dfd0 100644 --- a/src/view/com/composer/useExternalLinkFetch.ts +++ b/src/view/com/composer/useExternalLinkFetch.ts @@ -6,6 +6,7 @@ import {getPostAsQuote} from 'lib/link-meta/bsky' import {downloadAndResize} from 'lib/media/manip' import {isBskyPostUrl} from 'lib/strings/url-helpers' import {ComposerOpts} from 'state/models/ui/shell' +import {POST_IMG_MAX} from 'lib/constants' export function useExternalLinkFetch({ setQuote, @@ -55,13 +56,12 @@ export function useExternalLinkFetch({ return cleanup } if (extLink.isLoading && extLink.meta?.image && !extLink.localThumb) { - console.log('attempting download') downloadAndResize({ uri: extLink.meta.image, - width: 2000, - height: 2000, + width: POST_IMG_MAX.width, + height: POST_IMG_MAX.height, mode: 'contain', - maxSize: 1000000, + maxSize: POST_IMG_MAX.size, timeout: 15e3, }) .catch(() => undefined) diff --git a/src/view/com/modals/EditProfile.tsx b/src/view/com/modals/EditProfile.tsx index e6ef765a..0feae3a8 100644 --- a/src/view/com/modals/EditProfile.tsx +++ b/src/view/com/modals/EditProfile.tsx @@ -8,7 +8,7 @@ import { } from 'react-native' import LinearGradient from 'react-native-linear-gradient' import {ScrollView, TextInput} from './util' -import {PickedMedia} from '../../../lib/media/picker' +import {Image as RNImage} from 'react-native-image-crop-picker' import {Text} from '../util/text/Text' import {ErrorMessage} from '../util/error/ErrorMessage' import {useStores} from 'state/index' @@ -53,15 +53,15 @@ export function Component({ profileView.avatar, ) const [newUserBanner, setNewUserBanner] = useState< - PickedMedia | undefined | null + RNImage | undefined | null >() const [newUserAvatar, setNewUserAvatar] = useState< - PickedMedia | undefined | null + RNImage | undefined | null >() const onPressCancel = () => { store.shell.closeModal() } - const onSelectNewAvatar = async (img: PickedMedia | null) => { + const onSelectNewAvatar = async (img: RNImage | null) => { track('EditProfile:AvatarSelected') try { // if img is null, user selected "remove avatar" @@ -71,13 +71,13 @@ export function Component({ return } const finalImg = await compressIfNeeded(img, 1000000) - setNewUserAvatar({mediaType: 'photo', ...finalImg}) + setNewUserAvatar(finalImg) setUserAvatar(finalImg.path) } catch (e: any) { setError(cleanError(e)) } } - const onSelectNewBanner = async (img: PickedMedia | null) => { + const onSelectNewBanner = async (img: RNImage | null) => { if (!img) { setNewUserBanner(null) setUserBanner(null) @@ -86,7 +86,7 @@ export function Component({ track('EditProfile:BannerSelected') try { const finalImg = await compressIfNeeded(img, 1000000) - setNewUserBanner({mediaType: 'photo', ...finalImg}) + setNewUserBanner(finalImg) setUserBanner(finalImg.path) } catch (e: any) { setError(cleanError(e)) diff --git a/src/view/com/modals/crop-image/CropImage.web.tsx b/src/view/com/modals/crop-image/CropImage.web.tsx index 30668655..8a9b4bf6 100644 --- a/src/view/com/modals/crop-image/CropImage.web.tsx +++ b/src/view/com/modals/crop-image/CropImage.web.tsx @@ -4,7 +4,7 @@ import ImageEditor from 'react-avatar-editor' import {Slider} from '@miblanchard/react-native-slider' import LinearGradient from 'react-native-linear-gradient' import {Text} from 'view/com/util/text/Text' -import {PickedMedia} from 'lib/media/types' +import {Dimensions, Image} from 'lib/media/types' import {getDataUriSize} from 'lib/media/util' import {s, gradients} from 'lib/styles' import {useStores} from 'state/index' @@ -16,11 +16,8 @@ enum AspectRatio { Wide = 'wide', Tall = 'tall', } -interface Dim { - width: number - height: number -} -const DIMS: Record = { + +const DIMS: Record = { [AspectRatio.Square]: {width: 1000, height: 1000}, [AspectRatio.Wide]: {width: 1000, height: 750}, [AspectRatio.Tall]: {width: 750, height: 1000}, @@ -33,7 +30,7 @@ export function Component({ onSelect, }: { uri: string - onSelect: (img?: PickedMedia) => void + onSelect: (img?: Image) => void }) { const store = useStores() const pal = usePalette('default') @@ -52,7 +49,6 @@ export function Component({ if (canvas) { const dataUri = canvas.toDataURL('image/jpeg') onSelect({ - mediaType: 'photo', path: dataUri, mime: 'image/jpeg', size: getDataUriSize(dataUri), diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx index d18c2d69..e5c3cf60 100644 --- a/src/view/com/util/UserAvatar.tsx +++ b/src/view/com/util/UserAvatar.tsx @@ -4,12 +4,7 @@ import Svg, {Circle, Path} from 'react-native-svg' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {IconProp} from '@fortawesome/fontawesome-svg-core' import {HighPriorityImage} from 'view/com/util/images/Image' -import { - openCamera, - openCropper, - openPicker, - PickedMedia, -} from '../../../lib/media/picker' +import {openCamera, openCropper, openPicker} from '../../../lib/media/picker' import { usePhotoLibraryPermission, useCameraPermission, @@ -19,6 +14,7 @@ import {colors} from 'lib/styles' import {DropdownButton} from './forms/DropdownButton' import {usePalette} from 'lib/hooks/usePalette' import {isWeb} from 'platform/detection' +import {Image as RNImage} from 'react-native-image-crop-picker' function DefaultAvatar({size}: {size: number}) { return ( @@ -50,7 +46,7 @@ export function UserAvatar({ size: number avatar?: string | null hasWarning?: boolean - onSelectNewAvatar?: (img: PickedMedia | null) => void + onSelectNewAvatar?: (img: RNImage | null) => void }) { const store = useStores() const pal = usePalette('default') @@ -68,7 +64,6 @@ export function UserAvatar({ } onSelectNewAvatar?.( await openCamera(store, { - mediaType: 'photo', width: 1000, height: 1000, cropperCircleOverlay: true, @@ -84,9 +79,8 @@ export function UserAvatar({ if (!(await requestPhotoAccessIfNeeded())) { return } - const items = await openPicker(store, { - mediaType: 'photo', - }) + const items = await openPicker(store) + onSelectNewAvatar?.( await openCropper(store, { mediaType: 'photo', diff --git a/src/view/com/util/UserBanner.tsx b/src/view/com/util/UserBanner.tsx index d54b4150..40c82eaf 100644 --- a/src/view/com/util/UserBanner.tsx +++ b/src/view/com/util/UserBanner.tsx @@ -4,12 +4,8 @@ import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {IconProp} from '@fortawesome/fontawesome-svg-core' import {Image} from 'expo-image' import {colors} from 'lib/styles' -import { - openCamera, - openCropper, - openPicker, - PickedMedia, -} from '../../../lib/media/picker' +import {openCamera, openCropper, openPicker} from '../../../lib/media/picker' +import {Image as TImage} from 'lib/media/types' import {useStores} from 'state/index' import { usePhotoLibraryPermission, @@ -24,7 +20,7 @@ export function UserBanner({ onSelectNewBanner, }: { banner?: string | null - onSelectNewBanner?: (img: PickedMedia | null) => void + onSelectNewBanner?: (img: TImage | null) => void }) { const store = useStores() const pal = usePalette('default') @@ -42,7 +38,6 @@ export function UserBanner({ } onSelectNewBanner?.( await openCamera(store, { - mediaType: 'photo', // compressImageMaxWidth: 3000, TODO needed? width: 3000, // compressImageMaxHeight: 1000, TODO needed? @@ -59,9 +54,7 @@ export function UserBanner({ if (!(await requestPhotoAccessIfNeeded())) { return } - const items = await openPicker(store, { - mediaType: 'photo', - }) + const items = await openPicker(store) onSelectNewBanner?.( await openCropper(store, { mediaType: 'photo', diff --git a/src/view/com/util/images/ImageLayoutGrid.tsx b/src/view/com/util/images/ImageLayoutGrid.tsx index 83d98eec..7f9a6fdb 100644 --- a/src/view/com/util/images/ImageLayoutGrid.tsx +++ b/src/view/com/util/images/ImageLayoutGrid.tsx @@ -1,4 +1,5 @@ -import React from 'react' +import {Dimensions} from 'lib/media/types' +import React, {useState} from 'react' import { LayoutChangeEvent, StyleProp, @@ -11,11 +12,6 @@ import {Image, ImageStyle} from 'expo-image' export const DELAY_PRESS_IN = 500 -interface Dim { - width: number - height: number -} - export type ImageLayoutGridType = 'two' | 'three' | 'four' export function ImageLayoutGrid({ @@ -33,7 +29,7 @@ export function ImageLayoutGrid({ onPressIn?: (index: number) => void style?: StyleProp }) { - const [containerInfo, setContainerInfo] = React.useState() + const [containerInfo, setContainerInfo] = useState() const onLayout = (evt: LayoutChangeEvent) => { setContainerInfo({ @@ -71,7 +67,7 @@ function ImageLayoutGridInner({ onPress?: (index: number) => void onLongPress?: (index: number) => void onPressIn?: (index: number) => void - containerInfo: Dim + containerInfo: Dimensions }) { const size1 = React.useMemo(() => { if (type === 'three') {