diff options
Diffstat (limited to 'src/view/com/composer')
-rw-r--r-- | src/view/com/composer/Composer.tsx | 67 | ||||
-rw-r--r-- | src/view/com/composer/ExternalEmbed.tsx | 2 | ||||
-rw-r--r-- | src/view/com/composer/GifAltText.tsx | 2 | ||||
-rw-r--r-- | src/view/com/composer/photos/Gallery.tsx | 336 | ||||
-rw-r--r-- | src/view/com/composer/photos/OpenCameraBtn.tsx | 13 | ||||
-rw-r--r-- | src/view/com/composer/photos/SelectPhotoBtn.tsx | 21 | ||||
-rw-r--r-- | src/view/com/composer/useExternalLinkFetch.ts | 7 |
7 files changed, 247 insertions, 201 deletions
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 |