refactor: redo composer image state management

This commit is contained in:
Mary
2024-05-07 16:03:08 +07:00
parent 56f713077f
commit ba854ecc9a
11 changed files with 538 additions and 241 deletions
+14 -39
View File
@@ -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) {
+285
View File
@@ -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<ComposerImageWithoutTransformation> {
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<ComposerImageWithoutTransformation> {
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<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.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<ImageSource> {
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]
}
+5 -5
View File
@@ -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 {
+41 -25
View File
@@ -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<ComposerOpts['quote'] | undefined>(
initQuote,
)
@@ -130,10 +132,11 @@ export const ComposePost = observer(function ComposePost({
const [extGif, setExtGif] = useState<Gif>()
const [labels, setLabels] = useState<string[]>([])
const [threadgate, setThreadgate] = useState<ThreadgateSetting[]>([])
const gallery = useMemo(
() => new GalleryModel(initImageUris),
[initImageUris],
const [images, setImages] = useState<ComposerImage[]>(() =>
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({
/>
</View>
<Gallery gallery={gallery} />
{gallery.isEmpty && extLink && (
<Gallery images={images} onChange={setImages} />
{images.length === 0 && extLink && (
<View style={a.relative}>
<ExternalEmbed
link={extLink}
@@ -541,8 +553,12 @@ export const ComposePost = observer(function ComposePost({
<SuggestedLanguage text={richtext.text} />
<View style={[pal.border, styles.bottomBar]}>
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
<SelectPhotoBtn gallery={gallery} disabled={!canSelectImages} />
<OpenCameraBtn gallery={gallery} disabled={!canSelectImages} />
<SelectPhotoBtn
size={images.length}
disabled={!canSelectImages}
onAdd={onImageAdd}
/>
<OpenCameraBtn disabled={!canSelectImages} onAdd={onImageAdd} />
<SelectGifBtn
onClose={focusTextInput}
onSelectGif={onSelectGif}
@@ -577,7 +593,7 @@ export const ComposePost = observer(function ComposePost({
/>
</KeyboardAvoidingView>
)
})
}
const styles = StyleSheet.create({
outer: {
+1 -1
View File
@@ -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],
)
+140 -138
View File
@@ -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<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,15 +41,13 @@ 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 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 ? (
<>
<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,
})
}}
style={styles.altTextHiddenRegion}
/>
{images.map((image, index) => {
const 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,
}}
accessible={true}
accessibilityIgnoresInvertColors
/>
</View>
))}
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 (
<View
key={`selected-image-${image.source.path}`}
style={[imageStyle]}>
<TouchableOpacity
testID="altTextButton"
accessibilityRole="button"
accessibilityLabel={_(msg`Add alt text`)}
accessibilityHint=""
onPress={onAltText}
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={onEdit}
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={onAltText}
style={styles.altTextHiddenRegion}
/>
<Image
testID="selectedPhotoImage"
style={[styles.image, imageStyle] as ImageStyle}
source={{
uri: (image.transformed ?? image.source).path,
}}
accessible={true}
accessibilityIgnoresInvertColors
/>
</View>
)
})}
</View>
<AltTextReminder />
</>
) : null
})
}
export function AltTextReminder() {
const t = useTheme()
@@ -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,
@@ -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
@@ -1,6 +1,7 @@
import {useEffect, useState} from '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'
@@ -18,7 +19,6 @@ import {
isBskyListUrl,
isBskyPostUrl,
} from 'lib/strings/url-helpers'
import {ImageModel} from 'state/models/media/image'
import {ComposerOpts} from 'state/shell/composer'
export function useExternalLinkFetch({
@@ -118,14 +118,16 @@ 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
+13 -11
View File
@@ -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 {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)
@@ -59,7 +60,9 @@ export function Component({image}: Props) {
const imageStyles = useMemo<ImageStyle>(() => {
const maxWidth = isWeb ? 450 : windim.width
if (image.height > image.width) {
const source = image.transformed ?? image.source
if (source.height > source.width) {
return {
resizeMode: 'contain',
width: '100%',
@@ -69,7 +72,7 @@ export function Component({image}: Props) {
}
return {
width: '100%',
height: (maxWidth / image.width) * image.height,
height: (maxWidth / source.width) * source.height,
borderRadius: 8,
}
}, [image, windim])
@@ -78,15 +81,14 @@ 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)
closeModal()
}, [closeModal, image, altText])
onChange({...image, alt: altText.trim()})
}, [closeModal, image, altText, onChange])
return (
<ScrollView
@@ -101,7 +103,7 @@ export function Component({image}: Props) {
testID="selectedPhotoImage"
style={imageStyles}
source={{
uri: image.cropped?.path ?? image.path,
uri: (image.transformed ?? image.source).path,
}}
contentFit="contain"
accessible={true}
+10 -9
View File
@@ -1,22 +1,23 @@
import React, {useEffect} from 'react'
import {observer} from 'mobx-react-lite'
import {Animated, Easing, Platform, StyleSheet, View} from 'react-native'
import {ComposePost} from '../com/composer/Composer'
import {useComposerState} from 'state/shell/composer'
import {purgeTemporaryImageFiles} from '#/state/gallery'
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 const Composer = ({winHeight}: {winHeight: number}) => {
const state = useComposerState()
const pal = usePalette('default')
const initInterp = useAnimatedValue(0)
useEffect(() => {
if (state) {
if (process.env.NODE_ENV !== 'development') {
purgeTemporaryImageFiles()
}
Animated.timing(initInterp, {
toValue: 1,
duration: 300,
@@ -60,7 +61,7 @@ export const Composer = observer(function ComposerImpl({
/>
</Animated.View>
)
})
}
const styles = StyleSheet.create({
wrapper: {