diff options
Diffstat (limited to 'src/state')
-rw-r--r-- | src/state/gallery.ts | 299 | ||||
-rw-r--r-- | src/state/modals/index.tsx | 13 | ||||
-rw-r--r-- | src/state/models/media/gallery.ts | 110 | ||||
-rw-r--r-- | src/state/models/media/image.e2e.ts | 146 | ||||
-rw-r--r-- | src/state/models/media/image.ts | 310 | ||||
-rw-r--r-- | src/state/shell/composer/index.tsx | 7 |
6 files changed, 308 insertions, 577 deletions
diff --git a/src/state/gallery.ts b/src/state/gallery.ts new file mode 100644 index 000000000..f4c8b712e --- /dev/null +++ b/src/state/gallery.ts @@ -0,0 +1,299 @@ +import { + cacheDirectory, + deleteAsync, + makeDirectoryAsync, + moveAsync, +} from 'expo-file-system' +import { + Action, + ActionCrop, + manipulateAsync, + SaveFormat, +} from 'expo-image-manipulator' +import {nanoid} from 'nanoid/non-secure' + +import {POST_IMG_MAX} from '#/lib/constants' +import {getImageDim} from '#/lib/media/manip' +import {openCropper} from '#/lib/media/picker' +import {getDataUriSize} from '#/lib/media/util' +import {isIOS, isNative} from '#/platform/detection' + +export type ImageTransformation = { + crop?: ActionCrop['crop'] +} + +export type ImageMeta = { + path: string + width: number + height: number + mime: string +} + +export type ImageSource = ImageMeta & { + id: string +} + +type ComposerImageBase = { + alt: string + source: ImageSource +} +type ComposerImageWithoutTransformation = ComposerImageBase & { + transformed?: undefined + manips?: undefined +} +type ComposerImageWithTransformation = ComposerImageBase & { + transformed: ImageMeta + manips?: ImageTransformation +} + +export type ComposerImage = + | ComposerImageWithoutTransformation + | ComposerImageWithTransformation + +let _imageCacheDirectory: string + +function getImageCacheDirectory(): string | null { + if (isNative) { + return (_imageCacheDirectory ??= joinPath(cacheDirectory!, 'bsky-composer')) + } + + return null +} + +export async function createComposerImage( + raw: ImageMeta, +): Promise<ComposerImageWithoutTransformation> { + return { + alt: '', + source: { + id: nanoid(), + path: await moveIfNecessary(raw.path), + width: raw.width, + height: raw.height, + mime: raw.mime, + }, + } +} + +export type InitialImage = { + uri: string + width: number + height: number + altText?: string +} + +export function createInitialImages( + uris: InitialImage[] = [], +): ComposerImageWithoutTransformation[] { + return uris.map(({uri, width, height, altText = ''}) => { + return { + alt: altText, + source: { + id: nanoid(), + path: uri, + width: width, + height: height, + mime: 'image/jpeg', + }, + } + }) +} + +export async function pasteImage( + uri: string, +): Promise<ComposerImageWithoutTransformation> { + const {width, height} = await getImageDim(uri) + const match = /^data:(.+?);/.exec(uri) + + return { + alt: '', + source: { + id: nanoid(), + path: uri, + width: width, + height: height, + mime: match ? match[1] : 'image/jpeg', + }, + } +} + +export async function cropImage(img: ComposerImage): Promise<ComposerImage> { + if (!isNative) { + return img + } + + // NOTE + // on ios, react-native-image-crop-picker gives really bad quality + // without specifying width and height. on android, however, the + // crop stretches incorrectly if you do specify it. these are + // both separate bugs in the library. we deal with that by + // providing width & height for ios only + // -prf + + const source = img.source + const [w, h] = containImageRes(source.width, source.height, POST_IMG_MAX) + + // @todo: we're always passing the original image here, does image-cropper + // allows for setting initial crop dimensions? -mary + try { + const cropped = await openCropper({ + mediaType: 'photo', + path: source.path, + freeStyleCropEnabled: true, + ...(isIOS ? {width: w, height: h} : {}), + }) + + return { + alt: img.alt, + source: source, + transformed: { + path: await moveIfNecessary(cropped.path), + width: cropped.width, + height: cropped.height, + mime: cropped.mime, + }, + } + } catch (e) { + if (e instanceof Error && e.message.includes('User cancelled')) { + return img + } + + throw e + } +} + +export async function manipulateImage( + img: ComposerImage, + trans: ImageTransformation, +): Promise<ComposerImage> { + const rawActions: (Action | undefined)[] = [trans.crop && {crop: trans.crop}] + + const actions = rawActions.filter((a): a is Action => a !== undefined) + + if (actions.length === 0) { + if (img.transformed === undefined) { + return img + } + + return {alt: img.alt, source: img.source} + } + + const source = img.source + const result = await manipulateAsync(source.path, actions, { + format: SaveFormat.PNG, + }) + + return { + alt: img.alt, + source: img.source, + transformed: { + path: await moveIfNecessary(result.uri), + width: result.width, + height: result.height, + mime: 'image/png', + }, + manips: trans, + } +} + +export function resetImageManipulation( + img: ComposerImage, +): ComposerImageWithoutTransformation { + if (img.transformed !== undefined) { + return {alt: img.alt, source: img.source} + } + + return img +} + +export async function compressImage(img: ComposerImage): Promise<ImageMeta> { + const source = img.transformed || img.source + + const [w, h] = containImageRes(source.width, source.height, POST_IMG_MAX) + const cacheDir = isNative && getImageCacheDirectory() + + for (let i = 10; i > 0; i--) { + // Float precision + const factor = i / 10 + + const res = await manipulateAsync( + source.path, + [{resize: {width: w, height: h}}], + { + compress: factor, + format: SaveFormat.JPEG, + base64: true, + }, + ) + + const base64 = res.base64 + + if (base64 !== undefined && getDataUriSize(base64) <= POST_IMG_MAX.size) { + return { + path: await moveIfNecessary(res.uri), + width: res.width, + height: res.height, + mime: 'image/jpeg', + } + } + + if (cacheDir) { + await deleteAsync(res.uri) + } + } + + throw new Error(`Unable to compress image`) +} + +async function moveIfNecessary(from: string) { + const cacheDir = isNative && getImageCacheDirectory() + + if (cacheDir && from.startsWith(cacheDir)) { + const to = joinPath(cacheDir, nanoid(36)) + + await makeDirectoryAsync(cacheDir, {intermediates: true}) + await moveAsync({from, to}) + + return to + } + + return from +} + +/** Purge files that were created to accomodate image manipulation */ +export async function purgeTemporaryImageFiles() { + const cacheDir = isNative && getImageCacheDirectory() + + if (cacheDir) { + await deleteAsync(cacheDir, {idempotent: true}) + await makeDirectoryAsync(cacheDir) + } +} + +function joinPath(a: string, b: string) { + if (a.endsWith('/')) { + if (b.startsWith('/')) { + return a.slice(0, -1) + b + } + return a + b + } else if (b.startsWith('/')) { + return a + b + } + return a + '/' + b +} + +function containImageRes( + w: number, + h: number, + {width: maxW, height: maxH}: {width: number; height: number}, +): [width: number, height: number] { + let scale = 1 + + if (w > maxW || h > maxH) { + scale = w > h ? maxW / w : maxH / h + w = Math.floor(w * scale) + h = Math.floor(h * scale) + } + + return [w, h] +} diff --git a/src/state/modals/index.tsx b/src/state/modals/index.tsx index 529dc5590..467853a25 100644 --- a/src/state/modals/index.tsx +++ b/src/state/modals/index.tsx @@ -3,8 +3,7 @@ import {Image as RNImage} from 'react-native-image-crop-picker' import {AppBskyActorDefs, AppBskyGraphDefs} from '@atproto/api' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' -import {GalleryModel} from '#/state/models/media/gallery' -import {ImageModel} from '#/state/models/media/image' +import {ComposerImage} from '../gallery' export interface EditProfileModal { name: 'edit-profile' @@ -37,12 +36,6 @@ export interface ListAddRemoveUsersModal { ) => void } -export interface EditImageModal { - name: 'edit-image' - image: ImageModel - gallery: GalleryModel -} - export interface CropImageModal { name: 'crop-image' uri: string @@ -52,7 +45,8 @@ export interface CropImageModal { export interface AltTextImageModal { name: 'alt-text-image' - image: ImageModel + image: ComposerImage + onChange: (next: ComposerImage) => void } export interface DeleteAccountModal { @@ -139,7 +133,6 @@ export type Modal = // Posts | AltTextImageModal | CropImageModal - | EditImageModal | SelfLabelModal // Bluesky access diff --git a/src/state/models/media/gallery.ts b/src/state/models/media/gallery.ts deleted file mode 100644 index 828905002..000000000 --- a/src/state/models/media/gallery.ts +++ /dev/null @@ -1,110 +0,0 @@ -import {makeAutoObservable, runInAction} from 'mobx' - -import {getImageDim} from 'lib/media/manip' -import {openPicker} from 'lib/media/picker' -import {ImageInitOptions, ImageModel} from './image' - -interface InitialImageUri { - uri: string - width: number - height: number - altText?: string -} - -export class GalleryModel { - images: ImageModel[] = [] - - constructor(uris?: InitialImageUri[]) { - makeAutoObservable(this) - - if (uris) { - this.addFromUris(uris) - } - } - - get isEmpty() { - return this.size === 0 - } - - get size() { - return this.images.length - } - - get needsAltText() { - return this.images.some(image => image.altText.trim() === '') - } - - *add(image_: ImageInitOptions) { - 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(image_) - - // Initial resize - image.manipulate({}) - this.images.push(image) - } - } - - async paste(uri: string) { - if (this.size >= 4) { - return - } - - const {width, height} = await getImageDim(uri) - - const image = { - path: uri, - height, - width, - } - - runInAction(() => { - this.add(image) - }) - } - - setAltText(image: ImageModel, altText: string) { - image.setAltText(altText) - } - - crop(image: ImageModel) { - image.crop() - } - - remove(image: ImageModel) { - const index = this.images.findIndex(image_ => image_.path === image.path) - this.images.splice(index, 1) - } - - async previous(image: ImageModel) { - image.previous() - } - - async pick() { - const images = await openPicker({ - selectionLimit: 4 - this.size, - allowsMultipleSelection: true, - }) - - return await Promise.all( - images.map(image => { - this.add(image) - }), - ) - } - - async addFromUris(uris: InitialImageUri[]) { - for (const uriObj of uris) { - this.add({ - height: uriObj.height, - width: uriObj.width, - path: uriObj.uri, - altText: uriObj.altText, - }) - } - } -} diff --git a/src/state/models/media/image.e2e.ts b/src/state/models/media/image.e2e.ts deleted file mode 100644 index ccabd5047..000000000 --- a/src/state/models/media/image.e2e.ts +++ /dev/null @@ -1,146 +0,0 @@ -import {Image as RNImage} from 'react-native-image-crop-picker' -import {makeAutoObservable} from 'mobx' -import {POST_IMG_MAX} from 'lib/constants' -import {ActionCrop} from 'expo-image-manipulator' -import {Position} from 'react-avatar-editor' -import {Dimensions} from 'lib/media/types' - -export interface ImageManipulationAttributes { - aspectRatio?: '4:3' | '1:1' | '3:4' | 'None' - rotate?: number - scale?: number - position?: Position - flipHorizontal?: boolean - flipVertical?: boolean -} - -export class ImageModel implements Omit<RNImage, 'size'> { - path: string - mime = 'image/jpeg' - width: number - height: number - altText = '' - cropped?: RNImage = undefined - compressed?: RNImage = undefined - - // Web manipulation - prev?: RNImage - attributes: ImageManipulationAttributes = { - aspectRatio: 'None', - scale: 1, - flipHorizontal: false, - flipVertical: false, - rotate: 0, - } - prevAttributes: ImageManipulationAttributes = {} - - constructor(image: Omit<RNImage, 'size'>) { - makeAutoObservable(this) - - this.path = image.path - this.width = image.width - this.height = image.height - } - - setRatio(aspectRatio: ImageManipulationAttributes['aspectRatio']) { - this.attributes.aspectRatio = aspectRatio - } - - setRotate(degrees: number) { - this.attributes.rotate = degrees - this.manipulate({}) - } - - flipVertical() { - this.attributes.flipVertical = !this.attributes.flipVertical - this.manipulate({}) - } - - flipHorizontal() { - this.attributes.flipHorizontal = !this.attributes.flipHorizontal - this.manipulate({}) - } - - get ratioMultipliers() { - return { - '4:3': 4 / 3, - '1:1': 1, - '3:4': 3 / 4, - None: this.width / this.height, - } - } - - getUploadDimensions( - dimensions: Dimensions, - maxDimensions: Dimensions = POST_IMG_MAX, - as: ImageManipulationAttributes['aspectRatio'] = 'None', - ) { - const {width, height} = dimensions - const {width: maxWidth, height: maxHeight} = maxDimensions - - return width < maxWidth && height < maxHeight - ? { - width, - height, - } - : this.getResizedDimensions(as, POST_IMG_MAX.width) - } - - getResizedDimensions( - as: ImageManipulationAttributes['aspectRatio'] = 'None', - maxSide: number, - ) { - const ratioMultiplier = this.ratioMultipliers[as] - - if (ratioMultiplier === 1) { - return { - height: maxSide, - width: maxSide, - } - } - - if (ratioMultiplier < 1) { - return { - width: maxSide * ratioMultiplier, - height: maxSide, - } - } - - return { - width: maxSide, - height: maxSide / ratioMultiplier, - } - } - - setAltText(altText: string) { - this.altText = altText.trim() - } - - // Only compress prior to upload - async compress() { - // do nothing - } - - // Mobile - async crop() { - // do nothing - } - - // Web manipulation - async manipulate( - _attributes: { - crop?: ActionCrop['crop'] - } & ImageManipulationAttributes, - ) { - // do nothing - } - - resetCropped() { - this.manipulate({}) - } - - previous() { - this.cropped = this.prev - this.attributes = this.prevAttributes - } -} diff --git a/src/state/models/media/image.ts b/src/state/models/media/image.ts deleted file mode 100644 index 55f636491..000000000 --- a/src/state/models/media/image.ts +++ /dev/null @@ -1,310 +0,0 @@ -import {Image as RNImage} from 'react-native-image-crop-picker' -import * as ImageManipulator from 'expo-image-manipulator' -import {ActionCrop, FlipType, SaveFormat} from 'expo-image-manipulator' -import {makeAutoObservable, runInAction} from 'mobx' -import {Position} from 'react-avatar-editor' - -import {logger} from '#/logger' -import {POST_IMG_MAX} from 'lib/constants' -import {openCropper} from 'lib/media/picker' -import {Dimensions} from 'lib/media/types' -import {getDataUriSize} from 'lib/media/util' -import {isIOS} from 'platform/detection' - -export interface ImageManipulationAttributes { - aspectRatio?: '4:3' | '1:1' | '3:4' | 'None' - rotate?: number - scale?: number - position?: Position - flipHorizontal?: boolean - flipVertical?: boolean -} - -export interface ImageInitOptions { - path: string - width: number - height: number - altText?: string -} - -const MAX_IMAGE_SIZE_IN_BYTES = 976560 - -export class ImageModel implements Omit<RNImage, 'size'> { - path: string - mime = 'image/jpeg' - width: number - height: number - altText = '' - cropped?: RNImage = undefined - compressed?: RNImage = undefined - - // Web manipulation - prev?: RNImage - attributes: ImageManipulationAttributes = { - aspectRatio: 'None', - scale: 1, - flipHorizontal: false, - flipVertical: false, - rotate: 0, - } - prevAttributes: ImageManipulationAttributes = {} - - constructor(image: ImageInitOptions) { - makeAutoObservable(this) - - this.path = image.path - this.width = image.width - this.height = image.height - if (image.altText !== undefined) { - this.setAltText(image.altText) - } - } - - setRatio(aspectRatio: ImageManipulationAttributes['aspectRatio']) { - this.attributes.aspectRatio = aspectRatio - } - - setRotate(degrees: number) { - this.attributes.rotate = degrees - this.manipulate({}) - } - - flipVertical() { - this.attributes.flipVertical = !this.attributes.flipVertical - this.manipulate({}) - } - - flipHorizontal() { - this.attributes.flipHorizontal = !this.attributes.flipHorizontal - this.manipulate({}) - } - - get ratioMultipliers() { - return { - '4:3': 4 / 3, - '1:1': 1, - '3:4': 3 / 4, - None: this.width / this.height, - } - } - - getUploadDimensions( - dimensions: Dimensions, - maxDimensions: Dimensions = POST_IMG_MAX, - as: ImageManipulationAttributes['aspectRatio'] = 'None', - ) { - const {width, height} = dimensions - const {width: maxWidth, height: maxHeight} = maxDimensions - - return width < maxWidth && height < maxHeight - ? { - width, - height, - } - : this.getResizedDimensions(as, POST_IMG_MAX.width) - } - - getResizedDimensions( - as: ImageManipulationAttributes['aspectRatio'] = 'None', - maxSide: number, - ) { - const ratioMultiplier = this.ratioMultipliers[as] - - if (ratioMultiplier === 1) { - return { - height: maxSide, - width: maxSide, - } - } - - if (ratioMultiplier < 1) { - return { - width: maxSide * ratioMultiplier, - height: maxSide, - } - } - - return { - width: maxSide, - height: maxSide / ratioMultiplier, - } - } - - setAltText(altText: string) { - this.altText = altText.trim() - } - - // Only compress prior to upload - async compress() { - for (let i = 10; i > 0; i--) { - // Float precision - const factor = Math.round(i) / 10 - const compressed = await ImageManipulator.manipulateAsync( - this.cropped?.path ?? this.path, - undefined, - { - compress: factor, - base64: true, - format: SaveFormat.JPEG, - }, - ) - - if (compressed.base64 !== undefined) { - const size = getDataUriSize(compressed.base64) - - if (size < MAX_IMAGE_SIZE_IN_BYTES) { - runInAction(() => { - this.compressed = { - mime: 'image/jpeg', - path: compressed.uri, - size, - ...compressed, - } - }) - return - } - } - } - - // Compression fails when removing redundant information is not possible. - // This can be tested with images that have high variance in noise. - throw new Error('Failed to compress image') - } - - // Mobile - async crop() { - try { - // NOTE - // on ios, react-native-image-crop-picker gives really bad quality - // without specifying width and height. on android, however, the - // crop stretches incorrectly if you do specify it. these are - // both separate bugs in the library. we deal with that by - // providing width & height for ios only - // -prf - const {width, height} = this.getUploadDimensions({ - width: this.width, - height: this.height, - }) - - const cropped = await openCropper({ - mediaType: 'photo', - path: this.path, - freeStyleCropEnabled: true, - ...(isIOS ? {width, height} : {}), - }) - - runInAction(() => { - this.cropped = cropped - }) - } catch (err) { - logger.error('Failed to crop photo', {message: err}) - } - } - - // Web manipulation - async manipulate( - attributes: { - crop?: ActionCrop['crop'] - } & ImageManipulationAttributes, - ) { - let uploadWidth: number | undefined - let uploadHeight: number | undefined - - const {aspectRatio, crop, position, scale} = attributes - const modifiers = [] - - if (this.attributes.flipHorizontal) { - modifiers.push({flip: FlipType.Horizontal}) - } - - if (this.attributes.flipVertical) { - modifiers.push({flip: FlipType.Vertical}) - } - - if (this.attributes.rotate !== undefined) { - modifiers.push({rotate: this.attributes.rotate}) - } - - if (crop !== undefined) { - const croppedHeight = crop.height * this.height - const croppedWidth = crop.width * this.width - modifiers.push({ - crop: { - originX: crop.originX * this.width, - originY: crop.originY * this.height, - height: croppedHeight, - width: croppedWidth, - }, - }) - - const uploadDimensions = this.getUploadDimensions( - {width: croppedWidth, height: croppedHeight}, - POST_IMG_MAX, - aspectRatio, - ) - - uploadWidth = uploadDimensions.width - uploadHeight = uploadDimensions.height - } else { - const uploadDimensions = this.getUploadDimensions( - {width: this.width, height: this.height}, - POST_IMG_MAX, - aspectRatio, - ) - - uploadWidth = uploadDimensions.width - uploadHeight = uploadDimensions.height - } - - if (scale !== undefined) { - this.attributes.scale = scale - } - - if (position !== undefined) { - this.attributes.position = position - } - - if (aspectRatio !== undefined) { - this.attributes.aspectRatio = aspectRatio - } - - const ratioMultiplier = - this.ratioMultipliers[this.attributes.aspectRatio ?? '1:1'] - - const result = await ImageManipulator.manipulateAsync( - this.path, - [ - ...modifiers, - { - resize: - ratioMultiplier > 1 ? {width: uploadWidth} : {height: uploadHeight}, - }, - ], - { - base64: true, - format: SaveFormat.JPEG, - }, - ) - - runInAction(() => { - this.cropped = { - mime: 'image/jpeg', - path: result.uri, - size: - result.base64 !== undefined - ? getDataUriSize(result.base64) - : MAX_IMAGE_SIZE_IN_BYTES + 999, // shouldn't hit this unless manipulation fails - ...result, - } - }) - } - - resetCropped() { - this.manipulate({}) - } - - previous() { - this.cropped = this.prev - this.attributes = this.prevAttributes - } -} diff --git a/src/state/shell/composer/index.tsx b/src/state/shell/composer/index.tsx index 6755ec9a6..8e12386bd 100644 --- a/src/state/shell/composer/index.tsx +++ b/src/state/shell/composer/index.tsx @@ -9,6 +9,7 @@ import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' +import {purgeTemporaryImageFiles} from '#/state/gallery' import * as Toast from '#/view/com/util/Toast' export interface ComposerOptsPostRef { @@ -77,7 +78,11 @@ export function Provider({children}: React.PropsWithChildren<{}>) { const closeComposer = useNonReactiveCallback(() => { let wasOpen = !!state - setState(undefined) + if (wasOpen) { + setState(undefined) + purgeTemporaryImageFiles() + } + return wasOpen }) |