From ba854ecc9a481a7ab4ee708eccbd25fb0e25d7b0 Mon Sep 17 00:00:00 2001 From: Mary Date: Tue, 7 May 2024 16:03:08 +0700 Subject: [PATCH] refactor: redo composer image state management --- src/lib/api/index.ts | 53 +--- src/state/gallery.ts | 285 ++++++++++++++++++ src/state/modals/index.tsx | 10 +- src/view/com/composer/Composer.tsx | 66 ++-- src/view/com/composer/ExternalEmbed.tsx | 2 +- src/view/com/composer/photos/Gallery.tsx | 278 ++++++++--------- .../com/composer/photos/OpenCameraBtn.tsx | 13 +- .../com/composer/photos/SelectPhotoBtn.tsx | 21 +- src/view/com/composer/useExternalLinkFetch.ts | 8 +- src/view/com/modals/AltImage.tsx | 24 +- src/view/shell/Composer.tsx | 19 +- 11 files changed, 538 insertions(+), 241 deletions(-) create mode 100644 src/state/gallery.ts diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index bc50f9cb3c..7285d5bc64 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -13,20 +13,19 @@ import { import {AtUri} from '@atproto/api' import {logger} from '#/logger' +import {ComposerImage, compressImage} from '#/state/gallery' import {ThreadgateSetting} from '#/state/queries/threadgate' import {isNetworkError} from 'lib/strings/errors' import {shortenLinks} from 'lib/strings/rich-text-manip' -import {isNative, isWeb} from 'platform/detection' -import {ImageModel} from 'state/models/media/image' +import {isWeb} from 'platform/detection' import {LinkMeta} from '../link-meta/link-meta' -import {safeDeleteAsync} from '../media/manip' export interface ExternalEmbedDraft { uri: string isLoading: boolean meta?: LinkMeta embed?: AppBskyEmbedRecord.Main - localThumb?: ImageModel + localThumb?: ComposerImage } export async function uploadBlob( @@ -56,7 +55,7 @@ interface PostOpts { cid: string } extLink?: ExternalEmbedDraft - images?: ImageModel[] + images?: ComposerImage[] labels?: string[] threadgate?: ThreadgateSetting[] onStateChange?: (state: string) => void @@ -113,18 +112,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}, }) } @@ -154,32 +151,10 @@ 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/state/gallery.ts b/src/state/gallery.ts new file mode 100644 index 0000000000..ef7dfc3278 --- /dev/null +++ b/src/state/gallery.ts @@ -0,0 +1,285 @@ +import uuid from 'react-native-uuid' +import { + cacheDirectory, + deleteAsync, + makeDirectoryAsync, + moveAsync, +} from 'expo-file-system' +import { + Action, + ActionCrop, + ActionExtent, + ActionFlip, + ActionResize, + ActionRotate, + manipulateAsync, + SaveFormat, +} from 'expo-image-manipulator' + +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 = Partial< + ActionCrop & ActionExtent & ActionFlip & ActionResize & ActionRotate +> + +export type ImageSource = { + path: string + width: number + height: number + mime: string +} + +type ComposerImageBase = { + alt: string + source: ImageSource + compressed?: ImageSource +} +type ComposerImageWithoutTransformation = ComposerImageBase & { + transformed?: undefined +} +type ComposerImageWithTransformation = ComposerImageBase & { + transformed: ImageSource + transformations?: ImageTransformation +} + +export type ComposerImage = + | ComposerImageWithoutTransformation + | ComposerImageWithTransformation + +const imageCacheDirectory = isNative + ? joinPath(cacheDirectory!, 'bsky-composer') + : null + +export async function createComposerImage( + raw: ImageSource, +): Promise { + return { + alt: '', + source: { + path: await moveIfNecessary(raw.path), + width: raw.width, + height: raw.height, + mime: raw.mime, + }, + } +} + +export function createInitialImages( + uris: {uri: string; width: number; height: number}[] | undefined, +): ComposerImageWithoutTransformation[] { + if (uris === undefined) { + return [] + } + + return uris.map(({uri, width, height}) => { + return { + alt: '', + source: { + path: uri, + width: width, + height: height, + mime: 'image/jpeg', + }, + } + }) +} + +export async function pasteImage( + uri: string, +): Promise { + const {width, height} = await getImageDim(uri) + + return { + alt: '', + source: { + path: uri, + width: width, + height: height, + mime: 'image/jpeg', + }, + } +} + +export async function cropImage(img: ComposerImage): Promise { + 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 { + const rawActions: (Action | undefined)[] = [ + trans.extent && {extent: trans.extent}, + trans.flip && {flip: trans.flip}, + trans.rotate !== undefined ? {rotate: trans.rotate} : undefined, + trans.resize && {resize: trans.resize}, + 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', + }, + transformations: 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 { + const source = img.transformed || img.source + + const [w, h] = containImageRes(source.width, source.height, POST_IMG_MAX) + + 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 (imageCacheDirectory) { + await deleteAsync(res.uri) + } + } + + throw new Error(`Unable to compress image`) +} + +async function moveIfNecessary(from: string) { + if (cacheDirectory && from.startsWith(cacheDirectory!)) { + const to = joinPath(imageCacheDirectory!, uuid.v4() + '') + await moveAsync({from, to}) + + return to + } + + return from +} + +/** Purge files that were created to accomodate image manipulation */ +export async function purgeTemporaryImageFiles() { + if (imageCacheDirectory) { + await deleteAsync(imageCacheDirectory, {idempotent: true}) + await makeDirectoryAsync(imageCacheDirectory) + } +} + +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 cf82bcd075..b35ae57e0e 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' import {ThreadgateSetting} from '../queries/threadgate' export interface EditProfileModal { @@ -40,8 +39,8 @@ export interface ListAddRemoveUsersModal { export interface EditImageModal { name: 'edit-image' - image: ImageModel - gallery: GalleryModel + image: ComposerImage + onChange: (next: ComposerImage) => void } export interface CropImageModal { @@ -53,7 +52,8 @@ export interface CropImageModal { export interface AltTextImageModal { name: 'alt-text-image' - image: ImageModel + image: ComposerImage + onChange: (next: ComposerImage) => void } export interface DeleteAccountModal { diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 61c339024b..be685799ba 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -16,7 +16,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 { createGIFDescription, @@ -26,6 +25,7 @@ import {LikelyType} from '#/lib/link-meta/link-meta' import {logEvent} from '#/lib/statsig/statsig' import {logger} from '#/logger' import {emitPostCreated} from '#/state/events' +import {ComposerImage, createInitialImages, pasteImage} from '#/state/gallery' import {useModals} from '#/state/modals' import {useRequireAltTextEnabled} from '#/state/preferences' import { @@ -50,7 +50,6 @@ import {shortenLinks} from 'lib/strings/rich-text-manip' import {colors, gradients, s} from 'lib/styles' import {isAndroid, isIOS, isNative, isWeb} from 'platform/detection' import {useDialogStateControlContext} from 'state/dialogs' -import {GalleryModel} from 'state/models/media/gallery' import {ComposerOpts} from 'state/shell/composer' import {ComposerReplyTo} from 'view/com/composer/ComposerReplyTo' import {atoms as a} from '#/alf' @@ -77,8 +76,10 @@ import {TextInput, TextInputRef} from './text-input/TextInput' import {ThreadgateBtn} from './threadgate/ThreadgateBtn' import {useExternalLinkFetch} from './useExternalLinkFetch' +const MAX_IMAGES = 4 + type Props = ComposerOpts -export const ComposePost = observer(function ComposePost({ +export const ComposePost = ({ replyTo, onPost, quote: initQuote, @@ -86,7 +87,7 @@ export const ComposePost = observer(function ComposePost({ openPicker, text: initText, imageUris: initImageUris, -}: Props) { +}: Props) => { const {currentAccount} = useSession() const {getAgent} = useAgent() const {data: currentProfile} = useProfileQuery({did: currentAccount!.did}) @@ -123,6 +124,7 @@ export const ComposePost = observer(function ComposePost({ const graphemeLength = useMemo(() => { return shortenLinks(richtext).graphemeLength }, [richtext]) + const [quote, setQuote] = useState( initQuote, ) @@ -130,10 +132,11 @@ export const ComposePost = observer(function ComposePost({ const [extGif, setExtGif] = useState() const [labels, setLabels] = useState([]) const [threadgate, setThreadgate] = useState([]) - const gallery = useMemo( - () => new GalleryModel(initImageUris), - [initImageUris], + + const [images, setImages] = useState(() => + createInitialImages(initImageUris), ) + const onClose = useCallback(() => { closeComposer() }, [closeComposer]) @@ -149,7 +152,7 @@ export const ComposePost = observer(function ComposePost({ ) const onPressCancel = useCallback(() => { - if (graphemeLength > 0 || !gallery.isEmpty) { + if (graphemeLength > 0 || images.length !== 0) { closeAllDialogs() if (Keyboard) { Keyboard.dismiss() @@ -160,7 +163,7 @@ export const ComposePost = observer(function ComposePost({ } }, [ graphemeLength, - gallery.isEmpty, + images.length, closeAllDialogs, discardPromptControl, onClose, @@ -207,18 +210,27 @@ 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') - await gallery.paste(uri) + + const res = await pasteImage(uri) + onImageAdd([res]) }, - [gallery, track], + [track, 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 @@ -226,7 +238,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 = async () => { if (isProcessing || graphemeLength > MAX_GRAPHEME_LENGTH) { @@ -241,7 +253,7 @@ export const ComposePost = observer(function ComposePost({ if ( richtext.text.trim().length === 0 && - gallery.isEmpty && + images.length === 0 && !extLink && !quote ) { @@ -261,7 +273,7 @@ export const ComposePost = observer(function ComposePost({ await apilib.post(getAgent(), { rawText: richtext.text, replyTo: replyTo?.uri, - images: gallery.images, + images: images, quote, extLink, labels, @@ -273,7 +285,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) { @@ -289,7 +301,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, @@ -298,7 +310,7 @@ export const ComposePost = observer(function ComposePost({ }) } track('Create Post', { - imageCount: gallery.size, + imageCount: images.length, }) if (replyTo && replyTo.uri) track('Post:Reply') } @@ -323,8 +335,8 @@ export const ComposePost = observer(function ComposePost({ ? _(msg`Write your reply`) : _(msg`What's up?`) - const canSelectImages = gallery.size < 4 && !extLink - const hasMedia = gallery.size > 0 || Boolean(extLink) + const canSelectImages = images.length < MAX_IMAGES && !extLink + const hasMedia = images.length > 0 || Boolean(extLink) const onEmojiButtonPress = useCallback(() => { openPicker?.(textInput.current?.getCursorPosition()) @@ -509,8 +521,8 @@ export const ComposePost = observer(function ComposePost({ /> - - {gallery.isEmpty && extLink && ( + + {images.length === 0 && extLink && ( - - + + ) -}) +} const styles = StyleSheet.create({ outer: { diff --git a/src/view/com/composer/ExternalEmbed.tsx b/src/view/com/composer/ExternalEmbed.tsx index b81065e99d..680d3c1fcd 100644 --- a/src/view/com/composer/ExternalEmbed.tsx +++ b/src/view/com/composer/ExternalEmbed.tsx @@ -30,7 +30,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/photos/Gallery.tsx b/src/view/com/composer/photos/Gallery.tsx index 7ff1b7b9ab..886dc193e3 100644 --- a/src/view/com/composer/photos/Gallery.tsx +++ b/src/view/com/composer/photos/Gallery.tsx @@ -1,29 +1,29 @@ -import React, {useState} from 'react' +import React from 'react' import {ImageStyle, Keyboard, LayoutChangeEvent} from 'react-native' import {StyleSheet, TouchableOpacity, View} 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 {ComposerImage, cropImage} from '#/state/gallery' import {useModalControls} from '#/state/modals' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {Dimensions} from 'lib/media/types' import {colors, s} from 'lib/styles' import {isNative} from 'platform/detection' -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() +export let Gallery = (props: GalleryProps): React.ReactNode => { + const [containerInfo, setContainerInfo] = React.useState() const onLayout = (evt: LayoutChangeEvent) => { const {width, height} = evt.nativeEvent.layout @@ -41,15 +41,13 @@ export const Gallery = (props: GalleryProps) => { ) } +Gallery = React.memo(Gallery) interface GalleryInnerProps extends GalleryProps { containerInfo: Dimensions } -const GalleryInner = observer(function GalleryImpl({ - gallery, - containerInfo, -}: GalleryInnerProps) { +const GalleryInner = ({images, containerInfo, onChange}: GalleryInnerProps) => { const {_} = useLingui() const {isMobile} = useWebMediaQueries() const {openModal} = useModalControls() @@ -57,10 +55,11 @@ const GalleryInner = observer(function GalleryImpl({ let side: number - if (gallery.size === 1) { + if (images.length === 1) { side = 250 } else { - side = (containerInfo.width - IMAGE_GAP * (gallery.size - 1)) / gallery.size + side = + (containerInfo.width - IMAGE_GAP * (images.length - 1)) / images.length } const imageStyle = { @@ -68,150 +67,153 @@ const GalleryInner = observer(function GalleryImpl({ width: side, } - const isOverflow = isMobile && gallery.size > 2 + const isOverflow = isMobile && images.length > 2 const altTextControlStyle = isOverflow - ? { - left: 4, - bottom: 4, - } - : !isMobile && gallery.size < 3 - ? { - left: 8, - top: 8, - } - : { - left: 4, - top: 4, - } + ? {left: 4, bottom: 4} + : !isMobile && images.length < 3 + ? {left: 8, top: 8} + : {left: 4, top: 4} 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, - }), + ? {top: 4, right: 4, gap: 4} + : !isMobile && images.length < 3 + ? {top: 8, right: 8, gap: 8} + : {top: 4, right: 4, gap: 4}), zIndex: 1, } - return !gallery.isEmpty ? ( + return images.length !== 0 ? ( <> - {gallery.images.map(image => ( - - { - Keyboard.dismiss() - openModal({ - name: 'alt-text-image', - image, - }) - }} - style={[styles.altTextControl, altTextControlStyle]}> - {image.altText.length > 0 ? ( - - ) : ( - - )} - - ALT - - - - { - if (isNative) { - gallery.crop(image) - } else { - openModal({ - name: 'edit-image', - image, - gallery, - }) - } - }} - style={styles.imageControl}> - - - gallery.remove(image)} - style={styles.imageControl}> - - - - { - Keyboard.dismiss() - openModal({ - name: 'alt-text-image', - image, - }) - }} - style={styles.altTextHiddenRegion} - /> + {images.map((image, index) => { + const onRemove = () => { + const next = images.slice() + next.splice(index, 1) - - - ))} + onChange(next) + } + + const onAltText = () => { + Keyboard.dismiss() + openModal({ + name: 'alt-text-image', + image, + onChange(next) { + onChange( + images.map(i => (i.source === image.source ? next : i)), + ) + }, + }) + } + + const onEdit = () => { + if (isNative) { + cropImage(image).then(next => { + if (next === image) { + return + } + + onChange( + images.map(i => (i.source === image.source ? next : i)), + ) + }) + } else { + // openModal({ + // name: 'edit-image', + // image, + // gallery, + // }) + } + } + + return ( + + + {image.alt.length !== 0 ? ( + + ) : ( + + )} + + ALT + + + + {isNative && ( + + + + )} + + + + + + + + + ) + })} ) : null -}) +} 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 8f9152e34d..33eefd33f4 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 747653fc8d..95d2df022c 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 (