diff options
23 files changed, 594 insertions, 1256 deletions
diff --git a/package.json b/package.json index d3a94eb8d..117fc0b19 100644 --- a/package.json +++ b/package.json @@ -161,9 +161,6 @@ "lodash.set": "^4.3.2", "lodash.shuffle": "^4.2.0", "lodash.throttle": "^4.1.1", - "mobx": "^6.6.1", - "mobx-react-lite": "^3.4.0", - "mobx-utils": "^6.0.6", "nanoid": "^5.0.5", "normalize-url": "^8.0.0", "patch-package": "^6.5.1", diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index f6537e3d1..1e51c7f25 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -14,6 +14,7 @@ import { } from '@atproto/api' import {logger} from '#/logger' +import {ComposerImage, compressImage} from '#/state/gallery' import {writePostgateRecord} from '#/state/queries/postgate' import { createThreadgateRecord, @@ -23,10 +24,7 @@ import { } from '#/state/queries/threadgate' import {isNetworkError} from 'lib/strings/errors' import {shortenLinks, stripInvalidMentions} from 'lib/strings/rich-text-manip' -import {isNative} from 'platform/detection' -import {ImageModel} from 'state/models/media/image' import {LinkMeta} from '../link-meta/link-meta' -import {safeDeleteAsync} from '../media/manip' import {uploadBlob} from './upload-blob' export {uploadBlob} @@ -36,7 +34,7 @@ export interface ExternalEmbedDraft { isLoading: boolean meta?: LinkMeta embed?: AppBskyEmbedRecord.Main - localThumb?: ImageModel + localThumb?: ComposerImage } interface PostOpts { @@ -53,7 +51,7 @@ interface PostOpts { aspectRatio?: AppBskyEmbedDefs.AspectRatio } extLink?: ExternalEmbedDraft - images?: ImageModel[] + images?: ComposerImage[] labels?: string[] threadgate: ThreadgateAllowUISetting[] postgate: AppBskyFeedPostgate.Record @@ -99,18 +97,16 @@ export async function post(agent: BskyAgent, opts: PostOpts) { const images: AppBskyEmbedImages.Image[] = [] for (const image of opts.images) { opts.onStateChange?.(`Uploading image #${images.length + 1}...`) + logger.debug(`Compressing image`) - await image.compress() - const path = image.compressed?.path ?? image.path - const {width, height} = image.compressed || image + const {path, width, height, mime} = await compressImage(image) + logger.debug(`Uploading image`) - const res = await uploadBlob(agent, path, 'image/jpeg') - if (isNative) { - safeDeleteAsync(path) - } + const res = await uploadBlob(agent, path, mime) + images.push({ image: res.data.blob, - alt: image.altText ?? '', + alt: image.alt, aspectRatio: {width, height}, }) } @@ -175,32 +171,11 @@ export async function post(agent: BskyAgent, opts: PostOpts) { let thumb if (opts.extLink.localThumb) { opts.onStateChange?.('Uploading link thumbnail...') - let encoding - if (opts.extLink.localThumb.mime) { - encoding = opts.extLink.localThumb.mime - } else if (opts.extLink.localThumb.path.endsWith('.png')) { - encoding = 'image/png' - } else if ( - opts.extLink.localThumb.path.endsWith('.jpeg') || - opts.extLink.localThumb.path.endsWith('.jpg') - ) { - encoding = 'image/jpeg' - } else { - logger.warn('Unexpected image format for thumbnail, skipping', { - thumbnail: opts.extLink.localThumb.path, - }) - } - if (encoding) { - const thumbUploadRes = await uploadBlob( - agent, - opts.extLink.localThumb.path, - encoding, - ) - thumb = thumbUploadRes.data.blob - if (isNative) { - safeDeleteAsync(opts.extLink.localThumb.path) - } - } + + const {path, mime} = opts.extLink.localThumb.source + const res = await uploadBlob(agent, path, mime) + + thumb = res.data.blob } if (opts.quote) { diff --git a/src/lib/media/picker.shared.ts b/src/lib/media/picker.shared.ts index 9146cd778..b959ce8be 100644 --- a/src/lib/media/picker.shared.ts +++ b/src/lib/media/picker.shared.ts @@ -28,7 +28,7 @@ export async function openPicker(opts?: ImagePickerOptions) { return false }) .map(image => ({ - mime: 'image/jpeg', + mime: image.mimeType || 'image/jpeg', height: image.height, width: image.width, path: image.uri, 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 }) diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index dfdfb3ebd..3b7cf1385 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -44,7 +44,6 @@ import {RichText} from '@atproto/api' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {observer} from 'mobx-react-lite' import {useAnalytics} from '#/lib/analytics/analytics' import * as apilib from '#/lib/api/index' @@ -68,9 +67,9 @@ import {logger} from '#/logger' import {isAndroid, isIOS, isNative, isWeb} from '#/platform/detection' import {useDialogStateControlContext} from '#/state/dialogs' import {emitPostCreated} from '#/state/events' +import {ComposerImage, createInitialImages, pasteImage} from '#/state/gallery' import {useModalControls} from '#/state/modals' import {useModals} from '#/state/modals' -import {GalleryModel} from '#/state/models/media/gallery' import {useRequireAltTextEnabled} from '#/state/preferences' import { toPostLanguages, @@ -122,12 +121,14 @@ import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' import * as Prompt from '#/components/Prompt' import {Text as NewText} from '#/components/Typography' +const MAX_IMAGES = 4 + type CancelRef = { onPressCancel: () => void } type Props = ComposerOpts -export const ComposePost = observer(function ComposePost({ +export const ComposePost = ({ replyTo, onPost, quote: initQuote, @@ -139,7 +140,7 @@ export const ComposePost = observer(function ComposePost({ cancelRef, }: Props & { cancelRef?: React.RefObject<CancelRef> -}) { +}) => { const {currentAccount} = useSession() const agent = useAgent() const {data: currentProfile} = useProfileQuery({did: currentAccount!.did}) @@ -212,9 +213,8 @@ export const ComposePost = observer(function ComposePost({ ) const [postgate, setPostgate] = useState(createPostgateRecord({post: ''})) - const gallery = useMemo( - () => new GalleryModel(initImageUris), - [initImageUris], + const [images, setImages] = useState<ComposerImage[]>(() => + createInitialImages(initImageUris), ) const onClose = useCallback(() => { closeComposer() @@ -233,7 +233,7 @@ export const ComposePost = observer(function ComposePost({ const onPressCancel = useCallback(() => { if ( graphemeLength > 0 || - !gallery.isEmpty || + images.length !== 0 || extGif || videoUploadState.status !== 'idle' ) { @@ -246,7 +246,7 @@ export const ComposePost = observer(function ComposePost({ }, [ extGif, graphemeLength, - gallery.isEmpty, + images.length, closeAllDialogs, discardPromptControl, onClose, @@ -299,22 +299,31 @@ export const ComposePost = observer(function ComposePost({ [extLink, setExtLink], ) + const onImageAdd = useCallback( + (next: ComposerImage[]) => { + setImages(prev => prev.concat(next.slice(0, MAX_IMAGES - prev.length))) + }, + [setImages], + ) + const onPhotoPasted = useCallback( async (uri: string) => { track('Composer:PastedPhotos') if (uri.startsWith('data:video/')) { selectVideo({uri, type: 'video', height: 0, width: 0}) } else { - await gallery.paste(uri) + const res = await pasteImage(uri) + onImageAdd([res]) } }, - [gallery, track, selectVideo], + [track, selectVideo, onImageAdd], ) const isAltTextRequiredAndMissing = useMemo(() => { if (!requireAltTextEnabled) return false - if (gallery.needsAltText) return true + if (images.some(img => img.alt === '')) return true + if (extGif) { if (!extLink?.meta?.description) return true @@ -322,7 +331,7 @@ export const ComposePost = observer(function ComposePost({ if (!parsedAlt.isPreferred) return true } return false - }, [gallery.needsAltText, extLink, extGif, requireAltTextEnabled]) + }, [images, extLink, extGif, requireAltTextEnabled]) const onPressPublish = React.useCallback( async (finishedUploading?: boolean) => { @@ -347,7 +356,7 @@ export const ComposePost = observer(function ComposePost({ if ( richtext.text.trim().length === 0 && - gallery.isEmpty && + images.length === 0 && !extLink && !quote && videoUploadState.status === 'idle' @@ -368,7 +377,7 @@ export const ComposePost = observer(function ComposePost({ await apilib.post(agent, { rawText: richtext.text, replyTo: replyTo?.uri, - images: gallery.images, + images, quote, extLink, labels, @@ -405,7 +414,7 @@ export const ComposePost = observer(function ComposePost({ } catch (e: any) { logger.error(e, { message: `Composer: create post failed`, - hasImages: gallery.size > 0, + hasImages: images.length > 0, }) if (extLink) { @@ -427,7 +436,7 @@ export const ComposePost = observer(function ComposePost({ } finally { if (postUri) { logEvent('post:create', { - imageCount: gallery.size, + imageCount: images.length, isReply: replyTo != null, hasLink: extLink != null, hasQuote: quote != null, @@ -436,7 +445,7 @@ export const ComposePost = observer(function ComposePost({ }) } track('Create Post', { - imageCount: gallery.size, + imageCount: images.length, }) if (replyTo && replyTo.uri) track('Post:Reply') } @@ -472,9 +481,7 @@ export const ComposePost = observer(function ComposePost({ agent, captions, extLink, - gallery.images, - gallery.isEmpty, - gallery.size, + images, graphemeLength, isAltTextRequiredAndMissing, isProcessing, @@ -516,12 +523,12 @@ export const ComposePost = observer(function ComposePost({ : _(msg`What's up?`) const canSelectImages = - gallery.size < 4 && + images.length < MAX_IMAGES && !extLink && videoUploadState.status === 'idle' && !videoUploadState.video const hasMedia = - gallery.size > 0 || Boolean(extLink) || Boolean(videoUploadState.video) + images.length > 0 || Boolean(extLink) || Boolean(videoUploadState.video) const onEmojiButtonPress = useCallback(() => { openEmojiPicker?.(textInput.current?.getCursorPosition()) @@ -716,8 +723,8 @@ export const ComposePost = observer(function ComposePost({ /> </View> - <Gallery gallery={gallery} /> - {gallery.isEmpty && extLink && ( + <Gallery images={images} onChange={setImages} /> + {images.length === 0 && extLink && ( <View style={a.relative}> <ExternalEmbed link={extLink} @@ -801,13 +808,17 @@ export const ComposePost = observer(function ComposePost({ <VideoUploadToolbar state={videoUploadState} /> ) : ( <ToolbarWrapper style={[a.flex_row, a.align_center, a.gap_xs]}> - <SelectPhotoBtn gallery={gallery} disabled={!canSelectImages} /> + <SelectPhotoBtn + size={images.length} + disabled={!canSelectImages} + onAdd={onImageAdd} + /> <SelectVideoBtn onSelectVideo={selectVideo} disabled={!canSelectImages} setError={setError} /> - <OpenCameraBtn gallery={gallery} disabled={!canSelectImages} /> + <OpenCameraBtn disabled={!canSelectImages} onAdd={onImageAdd} /> <SelectGifBtn onClose={focusTextInput} onSelectGif={onSelectGif} @@ -842,7 +853,7 @@ export const ComposePost = observer(function ComposePost({ /> </KeyboardAvoidingView> ) -}) +} export function useComposerCancelRef() { return useRef<CancelRef>(null) diff --git a/src/view/com/composer/ExternalEmbed.tsx b/src/view/com/composer/ExternalEmbed.tsx index 4801ca0ab..f61d410df 100644 --- a/src/view/com/composer/ExternalEmbed.tsx +++ b/src/view/com/composer/ExternalEmbed.tsx @@ -26,7 +26,7 @@ export const ExternalEmbed = ({ title: link.meta?.title ?? link.uri, uri: link.uri, description: link.meta?.description ?? '', - thumb: link.localThumb?.path, + thumb: link.localThumb?.source.path, }, [link], ) diff --git a/src/view/com/composer/GifAltText.tsx b/src/view/com/composer/GifAltText.tsx index a37452604..a05607c76 100644 --- a/src/view/com/composer/GifAltText.tsx +++ b/src/view/com/composer/GifAltText.tsx @@ -43,7 +43,7 @@ export function GifAltText({ title: linkProp.meta?.title ?? linkProp.uri, uri: linkProp.uri, description: linkProp.meta?.description ?? '', - thumb: linkProp.localThumb?.path, + thumb: linkProp.localThumb?.source.path, }, params: parseEmbedPlayerFromUrl(linkProp.uri), } diff --git a/src/view/com/composer/photos/Gallery.tsx b/src/view/com/composer/photos/Gallery.tsx index 422a4dd93..775413e81 100644 --- a/src/view/com/composer/photos/Gallery.tsx +++ b/src/view/com/composer/photos/Gallery.tsx @@ -1,29 +1,36 @@ -import React, {useState} from 'react' -import {ImageStyle, Keyboard, LayoutChangeEvent} from 'react-native' -import {StyleSheet, TouchableOpacity, View} from 'react-native' +import React from 'react' +import { + ImageStyle, + Keyboard, + LayoutChangeEvent, + StyleSheet, + TouchableOpacity, + View, + ViewStyle, +} from 'react-native' import {Image} from 'expo-image' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {observer} from 'mobx-react-lite' import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' import {Dimensions} from '#/lib/media/types' import {colors, s} from '#/lib/styles' import {isNative} from '#/platform/detection' +import {ComposerImage, cropImage} from '#/state/gallery' import {useModalControls} from '#/state/modals' -import {GalleryModel} from '#/state/models/media/gallery' import {Text} from '#/view/com/util/text/Text' import {useTheme} from '#/alf' const IMAGE_GAP = 8 interface GalleryProps { - gallery: GalleryModel + images: ComposerImage[] + onChange: (next: ComposerImage[]) => void } -export const Gallery = (props: GalleryProps) => { - const [containerInfo, setContainerInfo] = useState<Dimensions | undefined>() +export let Gallery = (props: GalleryProps): React.ReactNode => { + const [containerInfo, setContainerInfo] = React.useState<Dimensions>() const onLayout = (evt: LayoutChangeEvent) => { const {width, height} = evt.nativeEvent.layout @@ -41,177 +48,190 @@ export const Gallery = (props: GalleryProps) => { </View> ) } +Gallery = React.memo(Gallery) interface GalleryInnerProps extends GalleryProps { containerInfo: Dimensions } -const GalleryInner = observer(function GalleryImpl({ - gallery, - containerInfo, -}: GalleryInnerProps) { - const {_} = useLingui() +const GalleryInner = ({images, containerInfo, onChange}: GalleryInnerProps) => { const {isMobile} = useWebMediaQueries() - const {openModal} = useModalControls() - const t = useTheme() - let side: number + const {altTextControlStyle, imageControlsStyle, imageStyle} = + React.useMemo(() => { + const side = + images.length === 1 + ? 250 + : (containerInfo.width - IMAGE_GAP * (images.length - 1)) / + images.length - if (gallery.size === 1) { - side = 250 - } else { - side = (containerInfo.width - IMAGE_GAP * (gallery.size - 1)) / gallery.size - } + const isOverflow = isMobile && images.length > 2 - const imageStyle = { - height: side, - width: side, - } - - const isOverflow = isMobile && gallery.size > 2 - - const altTextControlStyle = isOverflow - ? { - left: 4, - bottom: 4, - } - : !isMobile && gallery.size < 3 - ? { - left: 8, - top: 8, - } - : { - left: 4, - top: 4, + return { + altTextControlStyle: isOverflow + ? {left: 4, bottom: 4} + : !isMobile && images.length < 3 + ? {left: 8, top: 8} + : {left: 4, top: 4}, + imageControlsStyle: { + display: 'flex' as const, + flexDirection: 'row' as const, + position: 'absolute' as const, + ...(isOverflow + ? {top: 4, right: 4, gap: 4} + : !isMobile && images.length < 3 + ? {top: 8, right: 8, gap: 8} + : {top: 4, right: 4, gap: 4}), + zIndex: 1, + }, + imageStyle: { + height: side, + width: side, + }, } + }, [images.length, containerInfo, isMobile]) - const imageControlsStyle = { - display: 'flex' as const, - flexDirection: 'row' as const, - position: 'absolute' as const, - ...(isOverflow - ? { - top: 4, - right: 4, - gap: 4, - } - : !isMobile && gallery.size < 3 - ? { - top: 8, - right: 8, - gap: 8, - } - : { - top: 4, - right: 4, - gap: 4, - }), - zIndex: 1, - } - - return !gallery.isEmpty ? ( + return images.length !== 0 ? ( <> <View testID="selectedPhotosView" style={styles.gallery}> - {gallery.images.map(image => ( - <View key={`selected-image-${image.path}`} style={[imageStyle]}> - <TouchableOpacity - testID="altTextButton" - accessibilityRole="button" - accessibilityLabel={_(msg`Add alt text`)} - accessibilityHint="" - onPress={() => { - Keyboard.dismiss() - openModal({ - name: 'alt-text-image', - image, - }) - }} - style={[styles.altTextControl, altTextControlStyle]}> - {image.altText.length > 0 ? ( - <FontAwesomeIcon - icon="check" - size={10} - style={{color: t.palette.white}} - /> - ) : ( - <FontAwesomeIcon - icon="plus" - size={10} - style={{color: t.palette.white}} - /> - )} - <Text style={styles.altTextControlLabel} accessible={false}> - <Trans>ALT</Trans> - </Text> - </TouchableOpacity> - <View style={imageControlsStyle}> - <TouchableOpacity - testID="editPhotoButton" - accessibilityRole="button" - accessibilityLabel={_(msg`Edit image`)} - accessibilityHint="" - onPress={() => { - if (isNative) { - gallery.crop(image) - } else { - openModal({ - name: 'edit-image', - image, - gallery, - }) - } - }} - style={styles.imageControl}> - <FontAwesomeIcon - icon="pen" - size={12} - style={{color: colors.white}} - /> - </TouchableOpacity> - <TouchableOpacity - testID="removePhotoButton" - accessibilityRole="button" - accessibilityLabel={_(msg`Remove image`)} - accessibilityHint="" - onPress={() => gallery.remove(image)} - style={styles.imageControl}> - <FontAwesomeIcon - icon="xmark" - size={16} - style={{color: colors.white}} - /> - </TouchableOpacity> - </View> - <TouchableOpacity - accessibilityRole="button" - accessibilityLabel={_(msg`Add alt text`)} - accessibilityHint="" - onPress={() => { - Keyboard.dismiss() - openModal({ - name: 'alt-text-image', - image, - }) + {images.map((image, index) => { + return ( + <GalleryItem + key={image.source.id} + image={image} + altTextControlStyle={altTextControlStyle} + imageControlsStyle={imageControlsStyle} + imageStyle={imageStyle} + onChange={next => { + onChange( + images.map(i => (i.source === image.source ? next : i)), + ) }} - style={styles.altTextHiddenRegion} - /> + onRemove={() => { + const next = images.slice() + next.splice(index, 1) - <Image - testID="selectedPhotoImage" - style={[styles.image, imageStyle] as ImageStyle} - source={{ - uri: image.cropped?.path ?? image.path, + onChange(next) }} - accessible={true} - accessibilityIgnoresInvertColors /> - </View> - ))} + ) + })} </View> <AltTextReminder /> </> ) : null -}) +} + +type GalleryItemProps = { + image: ComposerImage + altTextControlStyle?: ViewStyle + imageControlsStyle?: ViewStyle + imageStyle?: ViewStyle + onChange: (next: ComposerImage) => void + onRemove: () => void +} + +const GalleryItem = ({ + image, + altTextControlStyle, + imageControlsStyle, + imageStyle, + onChange, + onRemove, +}: GalleryItemProps): React.ReactNode => { + const {_} = useLingui() + const t = useTheme() + const {openModal} = useModalControls() + + const onImageEdit = () => { + if (isNative) { + cropImage(image).then(next => { + onChange(next) + }) + } + } + + const onAltTextEdit = () => { + Keyboard.dismiss() + openModal({name: 'alt-text-image', image, onChange}) + } + + return ( + <View style={imageStyle}> + <TouchableOpacity + testID="altTextButton" + accessibilityRole="button" + accessibilityLabel={_(msg`Add alt text`)} + accessibilityHint="" + onPress={onAltTextEdit} + style={[styles.altTextControl, altTextControlStyle]}> + {image.alt.length !== 0 ? ( + <FontAwesomeIcon + icon="check" + size={10} + style={{color: t.palette.white}} + /> + ) : ( + <FontAwesomeIcon + icon="plus" + size={10} + style={{color: t.palette.white}} + /> + )} + <Text style={styles.altTextControlLabel} accessible={false}> + <Trans>ALT</Trans> + </Text> + </TouchableOpacity> + <View style={imageControlsStyle}> + {isNative && ( + <TouchableOpacity + testID="editPhotoButton" + accessibilityRole="button" + accessibilityLabel={_(msg`Edit image`)} + accessibilityHint="" + onPress={onImageEdit} + style={styles.imageControl}> + <FontAwesomeIcon + icon="pen" + size={12} + style={{color: colors.white}} + /> + </TouchableOpacity> + )} + <TouchableOpacity + testID="removePhotoButton" + accessibilityRole="button" + accessibilityLabel={_(msg`Remove image`)} + accessibilityHint="" + onPress={onRemove} + style={styles.imageControl}> + <FontAwesomeIcon + icon="xmark" + size={16} + style={{color: colors.white}} + /> + </TouchableOpacity> + </View> + <TouchableOpacity + accessibilityRole="button" + accessibilityLabel={_(msg`Add alt text`)} + accessibilityHint="" + onPress={onAltTextEdit} + style={styles.altTextHiddenRegion} + /> + + <Image + testID="selectedPhotoImage" + style={[styles.image, imageStyle] as ImageStyle} + source={{ + uri: (image.transformed ?? image.source).path, + }} + accessible={true} + accessibilityIgnoresInvertColors + /> + </View> + ) +} export function AltTextReminder() { const t = useTheme() diff --git a/src/view/com/composer/photos/OpenCameraBtn.tsx b/src/view/com/composer/photos/OpenCameraBtn.tsx index f1f984103..2183ca790 100644 --- a/src/view/com/composer/photos/OpenCameraBtn.tsx +++ b/src/view/com/composer/photos/OpenCameraBtn.tsx @@ -9,17 +9,17 @@ import {useCameraPermission} from '#/lib/hooks/usePermissions' import {openCamera} from '#/lib/media/picker' import {logger} from '#/logger' import {isMobileWeb, isNative} from '#/platform/detection' -import {GalleryModel} from '#/state/models/media/gallery' +import {ComposerImage, createComposerImage} from '#/state/gallery' import {atoms as a, useTheme} from '#/alf' import {Button} from '#/components/Button' import {Camera_Stroke2_Corner0_Rounded as Camera} from '#/components/icons/Camera' type Props = { - gallery: GalleryModel disabled?: boolean + onAdd: (next: ComposerImage[]) => void } -export function OpenCameraBtn({gallery, disabled}: Props) { +export function OpenCameraBtn({disabled, onAdd}: Props) { const {track} = useAnalytics() const {_} = useLingui() const {requestCameraAccessIfNeeded} = useCameraPermission() @@ -48,13 +48,16 @@ export function OpenCameraBtn({gallery, disabled}: Props) { if (mediaPermissionRes) { await MediaLibrary.createAssetAsync(img.path) } - gallery.add(img) + + const res = await createComposerImage(img) + + onAdd([res]) } catch (err: any) { // ignore logger.warn('Error using camera', {error: err}) } }, [ - gallery, + onAdd, track, requestCameraAccessIfNeeded, mediaPermissionRes, diff --git a/src/view/com/composer/photos/SelectPhotoBtn.tsx b/src/view/com/composer/photos/SelectPhotoBtn.tsx index 747653fc8..95d2df022 100644 --- a/src/view/com/composer/photos/SelectPhotoBtn.tsx +++ b/src/view/com/composer/photos/SelectPhotoBtn.tsx @@ -5,18 +5,20 @@ import {useLingui} from '@lingui/react' import {useAnalytics} from '#/lib/analytics/analytics' import {usePhotoLibraryPermission} from '#/lib/hooks/usePermissions' +import {openPicker} from '#/lib/media/picker' import {isNative} from '#/platform/detection' -import {GalleryModel} from '#/state/models/media/gallery' +import {ComposerImage, createComposerImage} from '#/state/gallery' import {atoms as a, useTheme} from '#/alf' import {Button} from '#/components/Button' import {Image_Stroke2_Corner0_Rounded as Image} from '#/components/icons/Image' type Props = { - gallery: GalleryModel + size: number disabled?: boolean + onAdd: (next: ComposerImage[]) => void } -export function SelectPhotoBtn({gallery, disabled}: Props) { +export function SelectPhotoBtn({size, disabled, onAdd}: Props) { const {track} = useAnalytics() const {_} = useLingui() const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission() @@ -29,8 +31,17 @@ export function SelectPhotoBtn({gallery, disabled}: Props) { return } - gallery.pick() - }, [track, requestPhotoAccessIfNeeded, gallery]) + const images = await openPicker({ + selectionLimit: 4 - size, + allowsMultipleSelection: true, + }) + + const results = await Promise.all( + images.map(img => createComposerImage(img)), + ) + + onAdd(results) + }, [track, requestPhotoAccessIfNeeded, size, onAdd]) return ( <Button diff --git a/src/view/com/composer/useExternalLinkFetch.ts b/src/view/com/composer/useExternalLinkFetch.ts index 317514437..1a36b5034 100644 --- a/src/view/com/composer/useExternalLinkFetch.ts +++ b/src/view/com/composer/useExternalLinkFetch.ts @@ -3,6 +3,7 @@ import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {logger} from '#/logger' +import {createComposerImage} from '#/state/gallery' import {useFetchDid} from '#/state/queries/handle' import {useGetPost} from '#/state/queries/post' import {useAgent} from '#/state/session' @@ -26,7 +27,6 @@ import { isBskyStartUrl, isShortLink, } from 'lib/strings/url-helpers' -import {ImageModel} from 'state/models/media/image' import {ComposerOpts} from 'state/shell/composer' export function useExternalLinkFetch({ @@ -161,14 +161,15 @@ export function useExternalLinkFetch({ timeout: 15e3, }) .catch(() => undefined) - .then(localThumb => { + .then(thumb => (thumb ? createComposerImage(thumb) : undefined)) + .then(thumb => { if (aborted) { return } setExtLink({ ...extLink, isLoading: false, // done - localThumb: localThumb ? new ImageModel(localThumb) : undefined, + localThumb: thumb, }) }) return cleanup diff --git a/src/view/com/modals/AltImage.tsx b/src/view/com/modals/AltImage.tsx index ba489cde7..c711f73a5 100644 --- a/src/view/com/modals/AltImage.tsx +++ b/src/view/com/modals/AltImage.tsx @@ -13,6 +13,7 @@ import {LinearGradient} from 'expo-linear-gradient' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {ComposerImage} from '#/state/gallery' import {useModalControls} from '#/state/modals' import {MAX_ALT_TEXT} from 'lib/constants' import {useIsKeyboardVisible} from 'lib/hooks/useIsKeyboardVisible' @@ -21,21 +22,21 @@ import {enforceLen} from 'lib/strings/helpers' import {gradients, s} from 'lib/styles' import {useTheme} from 'lib/ThemeContext' import {isAndroid, isWeb} from 'platform/detection' -import {ImageModel} from 'state/models/media/image' import {Text} from '../util/text/Text' import {ScrollView, TextInput} from './util' export const snapPoints = ['100%'] interface Props { - image: ImageModel + image: ComposerImage + onChange: (next: ComposerImage) => void } -export function Component({image}: Props) { +export function Component({image, onChange}: Props) { const pal = usePalette('default') const theme = useTheme() const {_} = useLingui() - const [altText, setAltText] = useState(image.altText) + const [altText, setAltText] = useState(image.alt) const windim = useWindowDimensions() const {closeModal} = useModalControls() const inputRef = React.useRef<RNTextInput>(null) @@ -60,7 +61,8 @@ export function Component({image}: Props) { const imageStyles = useMemo<ImageStyle>(() => { const maxWidth = isWeb ? 450 : windim.width - if (image.height > image.width) { + const media = image.transformed ?? image.source + if (media.height > media.width) { return { resizeMode: 'contain', width: '100%', @@ -70,7 +72,7 @@ export function Component({image}: Props) { } return { width: '100%', - height: (maxWidth / image.width) * image.height, + height: (maxWidth / media.width) * media.height, borderRadius: 8, } }, [image, windim]) @@ -79,15 +81,18 @@ export function Component({image}: Props) { (v: string) => { v = enforceLen(v, MAX_ALT_TEXT) setAltText(v) - image.setAltText(v) }, - [setAltText, image], + [setAltText], ) const onPressSave = useCallback(() => { - image.setAltText(altText) + onChange({ + ...image, + alt: altText, + }) + closeModal() - }, [closeModal, image, altText]) + }, [closeModal, image, altText, onChange]) return ( <ScrollView @@ -101,9 +106,7 @@ export function Component({image}: Props) { <Image testID="selectedPhotoImage" style={imageStyles} - source={{ - uri: image.cropped?.path ?? image.path, - }} + source={{uri: (image.transformed ?? image.source).path}} contentFit="contain" accessible={true} accessibilityIgnoresInvertColors diff --git a/src/view/com/modals/EditImage.tsx b/src/view/com/modals/EditImage.tsx deleted file mode 100644 index c921984d4..000000000 --- a/src/view/com/modals/EditImage.tsx +++ /dev/null @@ -1,380 +0,0 @@ -import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react' -import {Pressable, StyleSheet, View} from 'react-native' -import {useWindowDimensions} from 'react-native' -import {LinearGradient} from 'expo-linear-gradient' -import {msg, Trans} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import {Slider} from '@miblanchard/react-native-slider' -import {observer} from 'mobx-react-lite' -import ImageEditor, {Position} from 'react-avatar-editor' - -import {MAX_ALT_TEXT} from '#/lib/constants' -import {usePalette} from '#/lib/hooks/usePalette' -import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' -import {enforceLen} from '#/lib/strings/helpers' -import {gradients, s} from '#/lib/styles' -import {useTheme} from '#/lib/ThemeContext' -import {getKeys} from '#/lib/type-assertions' -import {useModalControls} from '#/state/modals' -import {GalleryModel} from '#/state/models/media/gallery' -import {ImageModel} from '#/state/models/media/image' -import {atoms as a} from '#/alf' -import {Button, ButtonIcon, ButtonText} from '#/components/Button' -import { - AspectRatio11_Stroke2_Corner0_Rounded as A11, - AspectRatio34_Stroke2_Corner0_Rounded as A34, - AspectRatio43_Stroke2_Corner0_Rounded as A43, -} from '#/components/icons/AspectRatio' -import {CircleBanSign_Stroke2_Corner0_Rounded as Ban} from '#/components/icons/CircleBanSign' -import { - FlipHorizontal_Stroke2_Corner0_Rounded as FlipHorizontal, - FlipVertical_Stroke2_Corner0_Rounded as FlipVertical, -} from '#/components/icons/FlipImage' -import {Text} from '../util/text/Text' -import {TextInput} from './util' - -export const snapPoints = ['80%'] - -const RATIOS = { - '4:3': { - icon: A43, - }, - '1:1': { - icon: A11, - }, - '3:4': { - icon: A34, - }, - None: { - icon: Ban, - }, -} as const - -type AspectRatio = keyof typeof RATIOS - -interface Props { - image: ImageModel - gallery: GalleryModel -} - -export const Component = observer(function EditImageImpl({ - image, - gallery, -}: Props) { - const pal = usePalette('default') - const theme = useTheme() - const {_} = useLingui() - const windowDimensions = useWindowDimensions() - const {isMobile} = useWebMediaQueries() - const {closeModal} = useModalControls() - - const { - aspectRatio, - // rotate = 0 - } = image.attributes - - const editorRef = useRef<ImageEditor>(null) - const [scale, setScale] = useState<number>(image.attributes.scale ?? 1) - const [position, setPosition] = useState<Position | undefined>( - image.attributes.position, - ) - const [altText, setAltText] = useState(image?.altText ?? '') - - const onFlipHorizontal = useCallback(() => { - image.flipHorizontal() - }, [image]) - - const onFlipVertical = useCallback(() => { - image.flipVertical() - }, [image]) - - // const onSetRotate = useCallback( - // (direction: 'left' | 'right') => { - // const rotation = (rotate + 90 * (direction === 'left' ? -1 : 1)) % 360 - // image.setRotate(rotation) - // }, - // [rotate, image], - // ) - - const onSetRatio = useCallback( - (ratio: AspectRatio) => { - image.setRatio(ratio) - }, - [image], - ) - - const adjustments = useMemo( - () => [ - // { - // name: 'rotate-left' as const, - // label: 'Rotate left', - // onPress: () => { - // onSetRotate('left') - // }, - // }, - // { - // name: 'rotate-right' as const, - // label: 'Rotate right', - // onPress: () => { - // onSetRotate('right') - // }, - // }, - { - icon: FlipHorizontal, - label: _(msg`Flip horizontal`), - onPress: onFlipHorizontal, - }, - { - icon: FlipVertical, - label: _(msg`Flip vertically`), - onPress: onFlipVertical, - }, - ], - [onFlipHorizontal, onFlipVertical, _], - ) - - useEffect(() => { - image.prev = image.cropped - image.prevAttributes = image.attributes - image.resetCropped() - }, [image]) - - const onCloseModal = useCallback(() => { - closeModal() - }, [closeModal]) - - const onPressCancel = useCallback(async () => { - await gallery.previous(image) - onCloseModal() - }, [onCloseModal, gallery, image]) - - const onPressSave = useCallback(async () => { - image.setAltText(altText) - - const crop = editorRef.current?.getCroppingRect() - - await image.manipulate({ - ...(crop !== undefined - ? { - crop: { - originX: crop.x, - originY: crop.y, - width: crop.width, - height: crop.height, - }, - ...(scale !== 1 ? {scale} : {}), - ...(position !== undefined ? {position} : {}), - } - : {}), - }) - - image.prev = image.cropped - image.prevAttributes = image.attributes - onCloseModal() - }, [altText, image, position, scale, onCloseModal]) - - if (image.cropped === undefined) { - return null - } - - const computedWidth = - windowDimensions.width > 500 ? 410 : windowDimensions.width - 80 - const sideLength = isMobile ? computedWidth : 300 - - const dimensions = image.getResizedDimensions(aspectRatio, sideLength) - const imgContainerStyles = {width: sideLength, height: sideLength} - - const imgControlStyles = { - alignItems: 'center' as const, - flexDirection: isMobile ? ('column' as const) : ('row' as const), - gap: isMobile ? 0 : 5, - } - - return ( - <View - testID="editImageModal" - style={[ - pal.view, - styles.container, - s.flex1, - { - paddingHorizontal: isMobile ? 16 : undefined, - }, - ]}> - <Text style={[styles.title, pal.text]}> - <Trans>Edit image</Trans> - </Text> - <View style={[styles.gap18, s.flexRow]}> - <View> - <View - style={[styles.imgContainer, pal.borderDark, imgContainerStyles]}> - <ImageEditor - ref={editorRef} - style={styles.imgEditor} - image={image.cropped.path} - scale={scale} - border={0} - position={position} - onPositionChange={setPosition} - {...dimensions} - /> - </View> - <Slider - value={scale} - onValueChange={(v: number | number[]) => - setScale(Array.isArray(v) ? v[0] : v) - } - minimumValue={1} - maximumValue={3} - /> - </View> - <View style={[a.gap_sm]}> - {!isMobile ? ( - <Text type="sm-bold" style={pal.text}> - <Trans>Ratios</Trans> - </Text> - ) : null} - <View style={imgControlStyles}> - {getKeys(RATIOS).map(ratio => { - const {icon} = RATIOS[ratio] - const isSelected = aspectRatio === ratio - - return ( - <Button - key={ratio} - label={ratio} - size="large" - shape="square" - variant="outline" - color={isSelected ? 'primary' : 'secondary'} - onPress={() => { - onSetRatio(ratio) - }}> - <View style={[a.align_center, a.gap_2xs]}> - <ButtonIcon icon={icon} /> - <ButtonText style={[a.text_xs]}>{ratio}</ButtonText> - </View> - </Button> - ) - })} - </View> - {!isMobile ? ( - <Text type="sm-bold" style={[pal.text, styles.subsection]}> - <Trans>Transformations</Trans> - </Text> - ) : null} - <View style={imgControlStyles}> - {adjustments.map(({label, icon, onPress}) => ( - <Button - key={label} - label={label} - size="large" - shape="square" - variant="outline" - color="secondary" - onPress={onPress}> - <ButtonIcon icon={icon} /> - </Button> - ))} - </View> - </View> - </View> - <View style={[styles.gap18, styles.bottomSection, pal.border]}> - <Text type="sm-bold" style={pal.text} nativeID="alt-text"> - <Trans>Accessibility</Trans> - </Text> - <TextInput - testID="altTextImageInput" - style={[ - styles.textArea, - pal.border, - pal.text, - { - maxHeight: isMobile ? 50 : undefined, - }, - ]} - keyboardAppearance={theme.colorScheme} - multiline - value={altText} - onChangeText={text => setAltText(enforceLen(text, MAX_ALT_TEXT))} - accessibilityLabel={_(msg`Alt text`)} - accessibilityHint="" - accessibilityLabelledBy="alt-text" - /> - </View> - <View style={styles.btns}> - <Pressable onPress={onPressCancel} accessibilityRole="button"> - <Text type="xl" style={pal.link}> - <Trans>Cancel</Trans> - </Text> - </Pressable> - <Pressable onPress={onPressSave} accessibilityRole="button"> - <LinearGradient - colors={[gradients.blueLight.start, gradients.blueLight.end]} - start={{x: 0, y: 0}} - end={{x: 1, y: 1}} - style={[styles.btn]}> - <Text type="xl-medium" style={s.white}> - <Trans context="action">Done</Trans> - </Text> - </LinearGradient> - </Pressable> - </View> - </View> - ) -}) - -const styles = StyleSheet.create({ - container: { - gap: 18, - height: '100%', - width: '100%', - }, - subsection: {marginTop: 12}, - gap18: {gap: 18}, - title: { - fontWeight: '600', - fontSize: 24, - }, - btns: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - }, - btn: { - borderRadius: 4, - paddingVertical: 8, - paddingHorizontal: 24, - }, - imgEditor: { - maxWidth: '100%', - }, - imgContainer: { - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - borderWidth: 1, - borderStyle: 'solid', - marginBottom: 4, - }, - flipVertical: { - transform: [{rotate: '90deg'}], - }, - flipBtn: { - paddingHorizontal: 4, - paddingVertical: 8, - }, - textArea: { - borderWidth: 1, - borderRadius: 6, - paddingTop: 10, - paddingHorizontal: 12, - fontSize: 16, - height: 100, - textAlignVertical: 'top', - }, - bottomSection: { - borderTopWidth: 1, - paddingTop: 18, - }, -}) diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx index 3455e1cdf..fd881ebc4 100644 --- a/src/view/com/modals/Modal.tsx +++ b/src/view/com/modals/Modal.tsx @@ -9,7 +9,6 @@ import {FullWindowOverlay} from '#/components/FullWindowOverlay' import {createCustomBackdrop} from '../util/BottomSheetCustomBackdrop' import * as AddAppPassword from './AddAppPasswords' import * as AltImageModal from './AltImage' -import * as EditImageModal from './AltImage' import * as ChangeEmailModal from './ChangeEmail' import * as ChangeHandleModal from './ChangeHandle' import * as ChangePasswordModal from './ChangePassword' @@ -78,9 +77,6 @@ export function ModalsContainer() { } else if (activeModal?.name === 'alt-text-image') { snapPoints = AltImageModal.snapPoints element = <AltImageModal.Component {...activeModal} /> - } else if (activeModal?.name === 'edit-image') { - snapPoints = AltImageModal.snapPoints - element = <EditImageModal.Component {...activeModal} /> } else if (activeModal?.name === 'change-handle') { snapPoints = ChangeHandleModal.snapPoints element = <ChangeHandleModal.Component {...activeModal} /> diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx index c4bab6fb1..fe24695d2 100644 --- a/src/view/com/modals/Modal.web.tsx +++ b/src/view/com/modals/Modal.web.tsx @@ -15,7 +15,6 @@ import * as ChangePasswordModal from './ChangePassword' import * as CreateOrEditListModal from './CreateOrEditList' import * as CropImageModal from './crop-image/CropImage.web' import * as DeleteAccountModal from './DeleteAccount' -import * as EditImageModal from './EditImage' import * as EditProfileModal from './EditProfile' import * as InviteCodesModal from './InviteCodes' import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings' @@ -54,11 +53,7 @@ function Modal({modal}: {modal: ModalIface}) { } const onPressMask = () => { - if ( - modal.name === 'crop-image' || - modal.name === 'edit-image' || - modal.name === 'alt-text-image' - ) { + if (modal.name === 'crop-image' || modal.name === 'alt-text-image') { return // dont close on mask presses during crop } closeModal() @@ -95,8 +90,6 @@ function Modal({modal}: {modal: ModalIface}) { element = <PostLanguagesSettingsModal.Component /> } else if (modal.name === 'alt-text-image') { element = <AltTextImageModal.Component {...modal} /> - } else if (modal.name === 'edit-image') { - element = <EditImageModal.Component {...modal} /> } else if (modal.name === 'verify-email') { element = <VerifyEmailModal.Component {...modal} /> } else if (modal.name === 'change-email') { diff --git a/src/view/shell/Composer.ios.tsx b/src/view/shell/Composer.ios.tsx index 7d3780801..bbb837f1f 100644 --- a/src/view/shell/Composer.ios.tsx +++ b/src/view/shell/Composer.ios.tsx @@ -2,16 +2,13 @@ import React, {useLayoutEffect} from 'react' import {Modal, View} from 'react-native' import {StatusBar} from 'expo-status-bar' import * as SystemUI from 'expo-system-ui' -import {observer} from 'mobx-react-lite' import {useComposerState} from '#/state/shell/composer' import {atoms as a, useTheme} from '#/alf' import {getBackgroundColor, useThemeName} from '#/alf/util/useColorModeTheme' import {ComposePost, useComposerCancelRef} from '../com/composer/Composer' -export const Composer = observer(function ComposerImpl({}: { - winHeight: number -}) { +export function Composer({}: {winHeight: number}) { const t = useTheme() const state = useComposerState() const ref = useComposerCancelRef() @@ -42,7 +39,7 @@ export const Composer = observer(function ComposerImpl({}: { </View> </Modal> ) -}) +} function Providers({ children, diff --git a/src/view/shell/Composer.tsx b/src/view/shell/Composer.tsx index 1c97df9c3..049f35d35 100644 --- a/src/view/shell/Composer.tsx +++ b/src/view/shell/Composer.tsx @@ -1,17 +1,12 @@ import React, {useEffect} from 'react' import {Animated, Easing, StyleSheet, View} from 'react-native' -import {observer} from 'mobx-react-lite' -import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' -import {usePalette} from 'lib/hooks/usePalette' -import {useComposerState} from 'state/shell/composer' +import {useAnimatedValue} from '#/lib/hooks/useAnimatedValue' +import {usePalette} from '#/lib/hooks/usePalette' +import {useComposerState} from '#/state/shell/composer' import {ComposePost} from '../com/composer/Composer' -export const Composer = observer(function ComposerImpl({ - winHeight, -}: { - winHeight: number -}) { +export function Composer({winHeight}: {winHeight: number}) { const state = useComposerState() const pal = usePalette('default') const initInterp = useAnimatedValue(0) @@ -62,7 +57,7 @@ export const Composer = observer(function ComposerImpl({ /> </Animated.View> ) -}) +} const styles = StyleSheet.create({ wrapper: { diff --git a/yarn.lock b/yarn.lock index 65b24915d..860b49dae 100644 --- a/yarn.lock +++ b/yarn.lock @@ -16760,21 +16760,6 @@ mkdirp@^1.0.3, mkdirp@^1.0.4: resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e" integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== -mobx-react-lite@^3.4.0: - version "3.4.3" - resolved "https://registry.yarnpkg.com/mobx-react-lite/-/mobx-react-lite-3.4.3.tgz#3a4c22c30bfaa8b1b2aa48d12b2ba811c0947ab7" - integrity sha512-NkJREyFTSUXR772Qaai51BnE1voWx56LOL80xG7qkZr6vo8vEaLF3sz1JNUVh+rxmUzxYaqOhfuxTfqUh0FXUg== - -mobx-utils@^6.0.6: - version "6.0.8" - resolved "https://registry.yarnpkg.com/mobx-utils/-/mobx-utils-6.0.8.tgz#843e222c7694050c2e42842682fd24a84fdb7024" - integrity sha512-fPNt0vJnHwbQx9MojJFEnJLfM3EMGTtpy4/qOOW6xueh1mPofMajrbYAUvByMYAvCJnpy1A5L0t+ZVB5niKO4g== - -mobx@^6.6.1: - version "6.10.0" - resolved "https://registry.yarnpkg.com/mobx/-/mobx-6.10.0.tgz#3537680fe98d45232cc19cc8f76280bd8bb6b0b7" - integrity sha512-WMbVpCMFtolbB8swQ5E2YRrU+Yu8iLozCVx3CdGjbBKlP7dFiCSuiG06uea3JCFN5DnvtAX7+G5Bp82e2xu0ww== - moo@^0.5.1: version "0.5.2" resolved "https://registry.yarnpkg.com/moo/-/moo-0.5.2.tgz#f9fe82473bc7c184b0d32e2215d3f6e67278733c" |