Split image cropping into secondary step (#473)

* Split image cropping into secondary step

* Use ImageModel and GalleryModel

* Add fix for pasting image URLs

* Move models to state folder

* Fix things that broke after rebase

* Latest -- has image display bug

* Remove contentFit

* Fix iOS display in gallery

* Tuneup the api signatures and implement compress/resize on web

* Fix await

* Lint fix and remove unused function

* Fix android image pathing

* Fix external embed x button on android

* Remove min-height from composer (no longer useful and was mispositioning the composer on android)

* Fix e2e picker

---------

Co-authored-by: Paul Frazee <pfrazee@gmail.com>
This commit is contained in:
Ollie Hsieh 2023-04-17 15:41:44 -07:00 committed by GitHub
parent 91fadadb58
commit 2509290fdd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 875 additions and 833 deletions

View file

@ -1,4 +1,4 @@
import React from 'react'
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'
import {observer} from 'mobx-react-lite'
import {
ActivityIndicator,
@ -30,47 +30,42 @@ import {sanitizeDisplayName} from 'lib/strings/display-names'
import {cleanError} from 'lib/strings/errors'
import {SelectPhotoBtn} from './photos/SelectPhotoBtn'
import {OpenCameraBtn} from './photos/OpenCameraBtn'
import {SelectedPhotos} from './photos/SelectedPhotos'
import {usePalette} from 'lib/hooks/usePalette'
import QuoteEmbed from '../util/post-embeds/QuoteEmbed'
import {useExternalLinkFetch} from './useExternalLinkFetch'
import {isDesktopWeb} from 'platform/detection'
import {GalleryModel} from 'state/models/media/gallery'
import {Gallery} from './photos/Gallery'
const MAX_GRAPHEME_LENGTH = 300
type Props = ComposerOpts & {
onClose: () => void
}
export const ComposePost = observer(function ComposePost({
replyTo,
onPost,
onClose,
quote: initQuote,
}: {
replyTo?: ComposerOpts['replyTo']
onPost?: ComposerOpts['onPost']
onClose: () => void
quote?: ComposerOpts['quote']
}) {
}: Props) {
const {track} = useAnalytics()
const pal = usePalette('default')
const store = useStores()
const textInput = React.useRef<TextInputRef>(null)
const [isProcessing, setIsProcessing] = React.useState(false)
const [processingState, setProcessingState] = React.useState('')
const [error, setError] = React.useState('')
const [richtext, setRichText] = React.useState(new RichText({text: ''}))
const graphemeLength = React.useMemo(
() => richtext.graphemeLength,
[richtext],
)
const [quote, setQuote] = React.useState<ComposerOpts['quote'] | undefined>(
const textInput = useRef<TextInputRef>(null)
const [isProcessing, setIsProcessing] = useState(false)
const [processingState, setProcessingState] = useState('')
const [error, setError] = useState('')
const [richtext, setRichText] = useState(new RichText({text: ''}))
const graphemeLength = useMemo(() => richtext.graphemeLength, [richtext])
const [quote, setQuote] = useState<ComposerOpts['quote'] | undefined>(
initQuote,
)
const {extLink, setExtLink} = useExternalLinkFetch({setQuote})
const [suggestedLinks, setSuggestedLinks] = React.useState<Set<string>>(
new Set(),
)
const [selectedPhotos, setSelectedPhotos] = React.useState<string[]>([])
const [suggestedLinks, setSuggestedLinks] = useState<Set<string>>(new Set())
const gallery = useMemo(() => new GalleryModel(store), [store])
const autocompleteView = React.useMemo<UserAutocompleteModel>(
const autocompleteView = useMemo<UserAutocompleteModel>(
() => new UserAutocompleteModel(store),
[store],
)
@ -82,17 +77,17 @@ export const ComposePost = observer(function ComposePost({
// is focused during unmount, an exception will throw (seems that a blur method isnt implemented)
// manually blurring before closing gets around that
// -prf
const hackfixOnClose = React.useCallback(() => {
const hackfixOnClose = useCallback(() => {
textInput.current?.blur()
onClose()
}, [textInput, onClose])
// initial setup
React.useEffect(() => {
useEffect(() => {
autocompleteView.setup()
}, [autocompleteView])
React.useEffect(() => {
useEffect(() => {
// HACK
// wait a moment before focusing the input to resolve some layout bugs with the keyboard-avoiding-view
// -prf
@ -109,60 +104,51 @@ export const ComposePost = observer(function ComposePost({
}
}, [])
const onPressContainer = React.useCallback(() => {
const onPressContainer = useCallback(() => {
textInput.current?.focus()
}, [textInput])
const onSelectPhotos = React.useCallback(
(photos: string[]) => {
track('Composer:SelectedPhotos')
setSelectedPhotos(photos)
},
[track, setSelectedPhotos],
)
const onPressAddLinkCard = React.useCallback(
const onPressAddLinkCard = useCallback(
(uri: string) => {
setExtLink({uri, isLoading: true})
},
[setExtLink],
)
const onPhotoPasted = React.useCallback(
const onPhotoPasted = useCallback(
async (uri: string) => {
if (selectedPhotos.length >= 4) {
return
}
onSelectPhotos([...selectedPhotos, uri])
track('Composer:PastedPhotos')
gallery.paste(uri)
},
[selectedPhotos, onSelectPhotos],
[gallery, track],
)
const onPressPublish = React.useCallback(async () => {
if (isProcessing) {
return
}
if (richtext.graphemeLength > MAX_GRAPHEME_LENGTH) {
const onPressPublish = useCallback(async () => {
if (isProcessing || richtext.graphemeLength > MAX_GRAPHEME_LENGTH) {
return
}
setError('')
if (richtext.text.trim().length === 0 && selectedPhotos.length === 0) {
if (richtext.text.trim().length === 0 && gallery.isEmpty) {
setError('Did you want to say anything?')
return false
}
setIsProcessing(true)
try {
await apilib.post(store, {
rawText: richtext.text,
replyTo: replyTo?.uri,
images: selectedPhotos,
images: gallery.paths,
quote: quote,
extLink: extLink,
onStateChange: setProcessingState,
knownHandles: autocompleteView.knownHandles,
})
track('Create Post', {
imageCount: selectedPhotos.length,
imageCount: gallery.size,
})
} catch (e: any) {
if (extLink) {
@ -191,34 +177,33 @@ export const ComposePost = observer(function ComposePost({
hackfixOnClose,
onPost,
quote,
selectedPhotos,
setExtLink,
store,
track,
gallery,
])
const canPost = graphemeLength <= MAX_GRAPHEME_LENGTH
const selectTextInputPlaceholder = replyTo
? 'Write your reply'
: selectedPhotos.length !== 0
: gallery.isEmpty
? 'Write a comment'
: "What's up?"
const canSelectImages = gallery.size <= 4
const viewStyles = {
paddingBottom: Platform.OS === 'android' ? insets.bottom : 0,
paddingTop: Platform.OS === 'android' ? insets.top : 15,
}
return (
<KeyboardAvoidingView
testID="composePostView"
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.outer}>
<TouchableWithoutFeedback onPressIn={onPressContainer}>
<View
style={[
s.flex1,
{
paddingBottom: Platform.OS === 'android' ? insets.bottom : 0,
paddingTop: Platform.OS === 'android' ? insets.top : 15,
},
]}>
<View style={[s.flex1, viewStyles]}>
<View style={styles.topbar}>
<TouchableOpacity
testID="composerCancelButton"
@ -301,11 +286,8 @@ export const ComposePost = observer(function ComposePost({
/>
</View>
<SelectedPhotos
selectedPhotos={selectedPhotos}
onSelectPhotos={onSelectPhotos}
/>
{selectedPhotos.length === 0 && extLink && (
<Gallery gallery={gallery} />
{gallery.isEmpty && extLink && (
<ExternalEmbed
link={extLink}
onRemove={() => setExtLink(undefined)}
@ -317,9 +299,7 @@ export const ComposePost = observer(function ComposePost({
</View>
) : undefined}
</ScrollView>
{!extLink &&
selectedPhotos.length === 0 &&
suggestedLinks.size > 0 ? (
{!extLink && suggestedLinks.size > 0 ? (
<View style={s.mb5}>
{Array.from(suggestedLinks).map(url => (
<TouchableOpacity
@ -335,16 +315,12 @@ export const ComposePost = observer(function ComposePost({
</View>
) : null}
<View style={[pal.border, styles.bottomBar]}>
<SelectPhotoBtn
enabled={selectedPhotos.length < 4}
selectedPhotos={selectedPhotos}
onSelectPhotos={setSelectedPhotos}
/>
<OpenCameraBtn
enabled={selectedPhotos.length < 4}
selectedPhotos={selectedPhotos}
onSelectPhotos={setSelectedPhotos}
/>
{canSelectImages ? (
<>
<SelectPhotoBtn gallery={gallery} />
<OpenCameraBtn gallery={gallery} />
</>
) : null}
<View style={s.flex1} />
<CharProgress count={graphemeLength} />
</View>

View file

@ -2,11 +2,10 @@ import React from 'react'
import {
ActivityIndicator,
StyleSheet,
TouchableWithoutFeedback,
TouchableOpacity,
View,
} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {BlurView} from '../util/BlurView'
import {AutoSizedImage} from '../util/images/AutoSizedImage'
import {Text} from '../util/text/Text'
import {s} from 'lib/styles'
@ -61,11 +60,9 @@ export const ExternalEmbed = ({
</Text>
)}
</View>
<TouchableWithoutFeedback onPress={onRemove}>
<BlurView style={styles.removeBtn} blurType="dark">
<FontAwesomeIcon size={18} icon="xmark" style={s.white} />
</BlurView>
</TouchableWithoutFeedback>
<TouchableOpacity style={styles.removeBtn} onPress={onRemove}>
<FontAwesomeIcon size={18} icon="xmark" style={s.white} />
</TouchableOpacity>
</View>
)
}
@ -92,6 +89,7 @@ const styles = StyleSheet.create({
right: 10,
width: 36,
height: 36,
backgroundColor: 'rgba(0, 0, 0, 0.75)',
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',

View file

@ -0,0 +1,130 @@
import React, {useCallback} from 'react'
import {GalleryModel} from 'state/models/media/gallery'
import {observer} from 'mobx-react-lite'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {colors} from 'lib/styles'
import {StyleSheet, TouchableOpacity, View} from 'react-native'
import {ImageModel} from 'state/models/media/image'
import {Image} from 'expo-image'
interface Props {
gallery: GalleryModel
}
export const Gallery = observer(function ({gallery}: Props) {
const getImageStyle = useCallback(() => {
switch (gallery.size) {
case 1:
return styles.image250
case 2:
return styles.image175
default:
return styles.image85
}
}, [gallery])
const imageStyle = getImageStyle()
const handleRemovePhoto = useCallback(
(image: ImageModel) => {
gallery.remove(image)
},
[gallery],
)
const handleEditPhoto = useCallback(
(image: ImageModel) => {
gallery.crop(image)
},
[gallery],
)
return !gallery.isEmpty ? (
<View testID="selectedPhotosView" style={styles.gallery}>
{gallery.images.map(image =>
image.compressed !== undefined ? (
<View
key={`selected-image-${image.path}`}
style={[styles.imageContainer, imageStyle]}>
<View style={styles.imageControls}>
<TouchableOpacity
testID="cropPhotoButton"
onPress={() => {
handleEditPhoto(image)
}}
style={styles.imageControl}>
<FontAwesomeIcon
icon="pen"
size={12}
style={{color: colors.white}}
/>
</TouchableOpacity>
<TouchableOpacity
testID="removePhotoButton"
onPress={() => handleRemovePhoto(image)}
style={styles.imageControl}>
<FontAwesomeIcon
icon="xmark"
size={16}
style={{color: colors.white}}
/>
</TouchableOpacity>
</View>
<Image
testID="selectedPhotoImage"
style={[styles.image, imageStyle]}
source={{
uri: image.compressed.path,
}}
/>
</View>
) : null,
)}
</View>
) : null
})
const styles = StyleSheet.create({
gallery: {
flex: 1,
flexDirection: 'row',
marginTop: 16,
},
imageContainer: {
margin: 2,
},
image: {
resizeMode: 'cover',
borderRadius: 8,
},
image250: {
width: 250,
height: 250,
},
image175: {
width: 175,
height: 175,
},
image85: {
width: 85,
height: 85,
},
imageControls: {
position: 'absolute',
display: 'flex',
flexDirection: 'row',
gap: 4,
top: 8,
right: 8,
zIndex: 1,
},
imageControl: {
width: 24,
height: 24,
borderRadius: 12,
backgroundColor: 'rgba(0, 0, 0, 0.75)',
borderWidth: 0.5,
alignItems: 'center',
justifyContent: 'center',
},
})

View file

@ -1,4 +1,4 @@
import React from 'react'
import React, {useCallback} from 'react'
import {TouchableOpacity} from 'react-native'
import {
FontAwesomeIcon,
@ -10,62 +10,44 @@ import {useStores} from 'state/index'
import {s} from 'lib/styles'
import {isDesktopWeb} from 'platform/detection'
import {openCamera} from 'lib/media/picker'
import {compressIfNeeded} from 'lib/media/manip'
import {useCameraPermission} from 'lib/hooks/usePermissions'
import {
POST_IMG_MAX_WIDTH,
POST_IMG_MAX_HEIGHT,
POST_IMG_MAX_SIZE,
} from 'lib/constants'
import {POST_IMG_MAX} from 'lib/constants'
import {GalleryModel} from 'state/models/media/gallery'
const HITSLOP = {left: 10, top: 10, right: 10, bottom: 10}
export function OpenCameraBtn({
enabled,
selectedPhotos,
onSelectPhotos,
}: {
enabled: boolean
selectedPhotos: string[]
onSelectPhotos: (v: string[]) => void
}) {
type Props = {
gallery: GalleryModel
}
export function OpenCameraBtn({gallery}: Props) {
const pal = usePalette('default')
const {track} = useAnalytics()
const store = useStores()
const {requestCameraAccessIfNeeded} = useCameraPermission()
const onPressTakePicture = React.useCallback(async () => {
const onPressTakePicture = useCallback(async () => {
track('Composer:CameraOpened')
if (!enabled) {
return
}
try {
if (!(await requestCameraAccessIfNeeded())) {
return
}
const cameraRes = await openCamera(store, {
mediaType: 'photo',
width: POST_IMG_MAX_WIDTH,
height: POST_IMG_MAX_HEIGHT,
const img = await openCamera(store, {
width: POST_IMG_MAX.width,
height: POST_IMG_MAX.height,
freeStyleCropEnabled: true,
})
const img = await compressIfNeeded(cameraRes, POST_IMG_MAX_SIZE)
onSelectPhotos([...selectedPhotos, img.path])
gallery.add(img)
} catch (err: any) {
// ignore
store.log.warn('Error using camera', err)
}
}, [
track,
store,
onSelectPhotos,
selectedPhotos,
enabled,
requestCameraAccessIfNeeded,
])
}, [gallery, track, store, requestCameraAccessIfNeeded])
if (isDesktopWeb) {
return <></>
return null
}
return (
@ -76,11 +58,7 @@ export function OpenCameraBtn({
hitSlop={HITSLOP}>
<FontAwesomeIcon
icon="camera"
style={
(enabled
? pal.link
: [pal.textLight, s.dimmed]) as FontAwesomeIconStyle
}
style={pal.link as FontAwesomeIconStyle}
size={24}
/>
</TouchableOpacity>

View file

@ -0,0 +1,3 @@
export function OpenCameraBtn() {
return null
}

View file

@ -1,86 +1,36 @@
import React from 'react'
import {Platform, TouchableOpacity} from 'react-native'
import React, {useCallback} from 'react'
import {TouchableOpacity} from 'react-native'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {usePalette} from 'lib/hooks/usePalette'
import {useAnalytics} from 'lib/analytics'
import {useStores} from 'state/index'
import {s} from 'lib/styles'
import {isDesktopWeb} from 'platform/detection'
import {openPicker, cropAndCompressFlow, pickImagesFlow} from 'lib/media/picker'
import {usePhotoLibraryPermission} from 'lib/hooks/usePermissions'
import {
POST_IMG_MAX_WIDTH,
POST_IMG_MAX_HEIGHT,
POST_IMG_MAX_SIZE,
} from 'lib/constants'
import {GalleryModel} from 'state/models/media/gallery'
const HITSLOP = {left: 10, top: 10, right: 10, bottom: 10}
export function SelectPhotoBtn({
enabled,
selectedPhotos,
onSelectPhotos,
}: {
enabled: boolean
selectedPhotos: string[]
onSelectPhotos: (v: string[]) => void
}) {
type Props = {
gallery: GalleryModel
}
export function SelectPhotoBtn({gallery}: Props) {
const pal = usePalette('default')
const {track} = useAnalytics()
const store = useStores()
const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
const onPressSelectPhotos = React.useCallback(async () => {
const onPressSelectPhotos = useCallback(async () => {
track('Composer:GalleryOpened')
if (!enabled) {
if (!isDesktopWeb && !(await requestPhotoAccessIfNeeded())) {
return
}
if (isDesktopWeb) {
const images = await pickImagesFlow(
store,
4 - selectedPhotos.length,
{width: POST_IMG_MAX_WIDTH, height: POST_IMG_MAX_HEIGHT},
POST_IMG_MAX_SIZE,
)
onSelectPhotos([...selectedPhotos, ...images])
} else {
if (!(await requestPhotoAccessIfNeeded())) {
return
}
const items = await openPicker(store, {
multiple: true,
maxFiles: 4 - selectedPhotos.length,
mediaType: 'photo',
})
const result = []
for (const image of items) {
if (Platform.OS === 'android') {
result.push(image.path)
continue
}
result.push(
await cropAndCompressFlow(
store,
image.path,
image,
{width: POST_IMG_MAX_WIDTH, height: POST_IMG_MAX_HEIGHT},
POST_IMG_MAX_SIZE,
),
)
}
onSelectPhotos([...selectedPhotos, ...result])
}
}, [
track,
store,
onSelectPhotos,
selectedPhotos,
enabled,
requestPhotoAccessIfNeeded,
])
gallery.pick()
}, [track, gallery, requestPhotoAccessIfNeeded])
return (
<TouchableOpacity
@ -90,11 +40,7 @@ export function SelectPhotoBtn({
hitSlop={HITSLOP}>
<FontAwesomeIcon
icon={['far', 'image']}
style={
(enabled
? pal.link
: [pal.textLight, s.dimmed]) as FontAwesomeIconStyle
}
style={pal.link as FontAwesomeIconStyle}
size={24}
/>
</TouchableOpacity>

View file

@ -1,96 +0,0 @@
import React, {useCallback} from 'react'
import {StyleSheet, TouchableOpacity, View} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {Image} from 'expo-image'
import {colors} from 'lib/styles'
export const SelectedPhotos = ({
selectedPhotos,
onSelectPhotos,
}: {
selectedPhotos: string[]
onSelectPhotos: (v: string[]) => void
}) => {
const imageStyle =
selectedPhotos.length === 1
? styles.image250
: selectedPhotos.length === 2
? styles.image175
: styles.image85
const handleRemovePhoto = useCallback(
item => {
onSelectPhotos(selectedPhotos.filter(filterItem => filterItem !== item))
},
[selectedPhotos, onSelectPhotos],
)
return selectedPhotos.length !== 0 ? (
<View testID="selectedPhotosView" style={styles.gallery}>
{selectedPhotos.length !== 0 &&
selectedPhotos.map((item, index) => (
<View
key={`selected-image-${index}`}
style={[styles.imageContainer, imageStyle]}>
<TouchableOpacity
testID="removePhotoButton"
onPress={() => handleRemovePhoto(item)}
style={styles.removePhotoButton}>
<FontAwesomeIcon
icon="xmark"
size={16}
style={{color: colors.white}}
/>
</TouchableOpacity>
<Image
testID="selectedPhotoImage"
style={[styles.image, imageStyle]}
source={{uri: item}}
/>
</View>
))}
</View>
) : null
}
const styles = StyleSheet.create({
gallery: {
flex: 1,
flexDirection: 'row',
marginTop: 16,
},
imageContainer: {
margin: 2,
},
image: {
resizeMode: 'cover',
borderRadius: 8,
},
image250: {
width: 250,
height: 250,
},
image175: {
width: 175,
height: 175,
},
image85: {
width: 85,
height: 85,
},
removePhotoButton: {
position: 'absolute',
top: 8,
right: 8,
width: 24,
height: 24,
borderRadius: 12,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.black,
zIndex: 1,
borderColor: colors.gray4,
borderWidth: 0.5,
},
})

View file

@ -1,4 +1,4 @@
import React from 'react'
import React, {forwardRef, useCallback, useEffect, useRef, useMemo} from 'react'
import {
NativeSyntheticEvent,
StyleSheet,
@ -14,18 +14,13 @@ import isEqual from 'lodash.isequal'
import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete'
import {Autocomplete} from './mobile/Autocomplete'
import {Text} from 'view/com/util/text/Text'
import {useStores} from 'state/index'
import {cleanError} from 'lib/strings/errors'
import {getImageDim} from 'lib/media/manip'
import {cropAndCompressFlow} from 'lib/media/picker'
import {getMentionAt, insertMentionAt} from 'lib/strings/mention-manip'
import {
POST_IMG_MAX_WIDTH,
POST_IMG_MAX_HEIGHT,
POST_IMG_MAX_SIZE,
} from 'lib/constants'
import {usePalette} from 'lib/hooks/usePalette'
import {useTheme} from 'lib/ThemeContext'
import {isUriImage} from 'lib/media/util'
import {downloadAndResize} from 'lib/media/manip'
import {POST_IMG_MAX} from 'lib/constants'
export interface TextInputRef {
focus: () => void
@ -48,7 +43,7 @@ interface Selection {
end: number
}
export const TextInput = React.forwardRef(
export const TextInput = forwardRef(
(
{
richtext,
@ -63,9 +58,8 @@ export const TextInput = React.forwardRef(
ref,
) => {
const pal = usePalette('default')
const store = useStores()
const textInput = React.useRef<PasteInputRef>(null)
const textInputSelection = React.useRef<Selection>({start: 0, end: 0})
const textInput = useRef<PasteInputRef>(null)
const textInputSelection = useRef<Selection>({start: 0, end: 0})
const theme = useTheme()
React.useImperativeHandle(ref, () => ({
@ -73,7 +67,7 @@ export const TextInput = React.forwardRef(
blur: () => textInput.current?.blur(),
}))
React.useEffect(() => {
useEffect(() => {
// HACK
// wait a moment before focusing the input to resolve some layout bugs with the keyboard-avoiding-view
// -prf
@ -90,8 +84,8 @@ export const TextInput = React.forwardRef(
}
}, [])
const onChangeText = React.useCallback(
(newText: string) => {
const onChangeText = useCallback(
async (newText: string) => {
const newRt = new RichText({text: newText})
newRt.detectFacetsWithoutResolution()
setRichText(newRt)
@ -108,50 +102,62 @@ export const TextInput = React.forwardRef(
}
const set: Set<string> = new Set()
if (newRt.facets) {
for (const facet of newRt.facets) {
for (const feature of facet.features) {
if (AppBskyRichtextFacet.isLink(feature)) {
set.add(feature.uri)
if (isUriImage(feature.uri)) {
const res = await downloadAndResize({
uri: feature.uri,
width: POST_IMG_MAX.width,
height: POST_IMG_MAX.height,
mode: 'contain',
maxSize: POST_IMG_MAX.size,
timeout: 15e3,
})
if (res !== undefined) {
onPhotoPasted(res.path)
}
} else {
set.add(feature.uri)
}
}
}
}
}
if (!isEqual(set, suggestedLinks)) {
onSuggestedLinksChanged(set)
}
},
[setRichText, autocompleteView, suggestedLinks, onSuggestedLinksChanged],
[
setRichText,
autocompleteView,
suggestedLinks,
onSuggestedLinksChanged,
onPhotoPasted,
],
)
const onPaste = React.useCallback(
const onPaste = useCallback(
async (err: string | undefined, files: PastedFile[]) => {
if (err) {
return onError(cleanError(err))
}
const uris = files.map(f => f.uri)
const imgUri = uris.find(uri => /\.(jpe?g|png)$/.test(uri))
if (imgUri) {
let imgDim
try {
imgDim = await getImageDim(imgUri)
} catch (e) {
imgDim = {width: POST_IMG_MAX_WIDTH, height: POST_IMG_MAX_HEIGHT}
}
const finalImgPath = await cropAndCompressFlow(
store,
imgUri,
imgDim,
{width: POST_IMG_MAX_WIDTH, height: POST_IMG_MAX_HEIGHT},
POST_IMG_MAX_SIZE,
)
onPhotoPasted(finalImgPath)
const uri = uris.find(isUriImage)
if (uri) {
onPhotoPasted(uri)
}
},
[store, onError, onPhotoPasted],
[onError, onPhotoPasted],
)
const onSelectionChange = React.useCallback(
const onSelectionChange = useCallback(
(evt: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => {
// NOTE we track the input selection using a ref to avoid excessive renders -prf
textInputSelection.current = evt.nativeEvent.selection
@ -159,7 +165,7 @@ export const TextInput = React.forwardRef(
[textInputSelection],
)
const onSelectAutocompleteItem = React.useCallback(
const onSelectAutocompleteItem = useCallback(
(item: string) => {
onChangeText(
insertMentionAt(
@ -173,23 +179,19 @@ export const TextInput = React.forwardRef(
[onChangeText, richtext, autocompleteView],
)
const textDecorated = React.useMemo(() => {
const textDecorated = useMemo(() => {
let i = 0
return Array.from(richtext.segments()).map(segment => {
if (!segment.facet) {
return (
<Text key={i++} style={[pal.text, styles.textInputFormatting]}>
{segment.text}
</Text>
)
} else {
return (
<Text key={i++} style={[pal.link, styles.textInputFormatting]}>
{segment.text}
</Text>
)
}
})
return Array.from(richtext.segments()).map(segment => (
<Text
key={i++}
style={[
!segment.facet ? pal.text : pal.link,
styles.textInputFormatting,
]}>
{segment.text}
</Text>
))
}, [richtext, pal.link, pal.text])
return (
@ -223,7 +225,6 @@ const styles = StyleSheet.create({
textInput: {
flex: 1,
width: '100%',
minHeight: 80,
padding: 5,
paddingBottom: 20,
marginLeft: 8,

View file

@ -12,6 +12,7 @@ import isEqual from 'lodash.isequal'
import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete'
import {createSuggestion} from './web/Autocomplete'
import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
import {isUriImage, blobToDataUri} from 'lib/media/util'
export interface TextInputRef {
focus: () => void
@ -37,7 +38,7 @@ export const TextInput = React.forwardRef(
suggestedLinks,
autocompleteView,
setRichText,
// onPhotoPasted, TODO
onPhotoPasted,
onSuggestedLinksChanged,
}: // onError, TODO
TextInputProps,
@ -72,6 +73,15 @@ export const TextInput = React.forwardRef(
attributes: {
class: modeClass,
},
handlePaste: (_, event) => {
const items = event.clipboardData?.items
if (items === undefined) {
return
}
getImageFromUri(items, onPhotoPasted)
},
},
content: richtext.text.toString(),
autofocus: true,
@ -147,3 +157,33 @@ const styles = StyleSheet.create({
marginBottom: 10,
},
})
function getImageFromUri(
items: DataTransferItemList,
callback: (uri: string) => void,
) {
for (let index = 0; index < items.length; index++) {
const item = items[index]
const {kind, type} = item
if (type === 'text/plain') {
item.getAsString(async itemString => {
if (isUriImage(itemString)) {
const response = await fetch(itemString)
const blob = await response.blob()
blobToDataUri(blob).then(callback, err => console.error(err))
}
})
}
if (kind === 'file') {
const file = item.getAsFile()
if (file instanceof Blob) {
blobToDataUri(new Blob([file], {type: item.type})).then(callback, err =>
console.error(err),
)
}
}
}
}

View file

@ -6,6 +6,7 @@ import {getPostAsQuote} from 'lib/link-meta/bsky'
import {downloadAndResize} from 'lib/media/manip'
import {isBskyPostUrl} from 'lib/strings/url-helpers'
import {ComposerOpts} from 'state/models/ui/shell'
import {POST_IMG_MAX} from 'lib/constants'
export function useExternalLinkFetch({
setQuote,
@ -55,13 +56,12 @@ export function useExternalLinkFetch({
return cleanup
}
if (extLink.isLoading && extLink.meta?.image && !extLink.localThumb) {
console.log('attempting download')
downloadAndResize({
uri: extLink.meta.image,
width: 2000,
height: 2000,
width: POST_IMG_MAX.width,
height: POST_IMG_MAX.height,
mode: 'contain',
maxSize: 1000000,
maxSize: POST_IMG_MAX.size,
timeout: 15e3,
})
.catch(() => undefined)

View file

@ -8,7 +8,7 @@ import {
} from 'react-native'
import LinearGradient from 'react-native-linear-gradient'
import {ScrollView, TextInput} from './util'
import {PickedMedia} from '../../../lib/media/picker'
import {Image as RNImage} from 'react-native-image-crop-picker'
import {Text} from '../util/text/Text'
import {ErrorMessage} from '../util/error/ErrorMessage'
import {useStores} from 'state/index'
@ -53,15 +53,15 @@ export function Component({
profileView.avatar,
)
const [newUserBanner, setNewUserBanner] = useState<
PickedMedia | undefined | null
RNImage | undefined | null
>()
const [newUserAvatar, setNewUserAvatar] = useState<
PickedMedia | undefined | null
RNImage | undefined | null
>()
const onPressCancel = () => {
store.shell.closeModal()
}
const onSelectNewAvatar = async (img: PickedMedia | null) => {
const onSelectNewAvatar = async (img: RNImage | null) => {
track('EditProfile:AvatarSelected')
try {
// if img is null, user selected "remove avatar"
@ -71,13 +71,13 @@ export function Component({
return
}
const finalImg = await compressIfNeeded(img, 1000000)
setNewUserAvatar({mediaType: 'photo', ...finalImg})
setNewUserAvatar(finalImg)
setUserAvatar(finalImg.path)
} catch (e: any) {
setError(cleanError(e))
}
}
const onSelectNewBanner = async (img: PickedMedia | null) => {
const onSelectNewBanner = async (img: RNImage | null) => {
if (!img) {
setNewUserBanner(null)
setUserBanner(null)
@ -86,7 +86,7 @@ export function Component({
track('EditProfile:BannerSelected')
try {
const finalImg = await compressIfNeeded(img, 1000000)
setNewUserBanner({mediaType: 'photo', ...finalImg})
setNewUserBanner(finalImg)
setUserBanner(finalImg.path)
} catch (e: any) {
setError(cleanError(e))

View file

@ -4,7 +4,7 @@ import ImageEditor from 'react-avatar-editor'
import {Slider} from '@miblanchard/react-native-slider'
import LinearGradient from 'react-native-linear-gradient'
import {Text} from 'view/com/util/text/Text'
import {PickedMedia} from 'lib/media/types'
import {Dimensions, Image} from 'lib/media/types'
import {getDataUriSize} from 'lib/media/util'
import {s, gradients} from 'lib/styles'
import {useStores} from 'state/index'
@ -16,11 +16,8 @@ enum AspectRatio {
Wide = 'wide',
Tall = 'tall',
}
interface Dim {
width: number
height: number
}
const DIMS: Record<string, Dim> = {
const DIMS: Record<string, Dimensions> = {
[AspectRatio.Square]: {width: 1000, height: 1000},
[AspectRatio.Wide]: {width: 1000, height: 750},
[AspectRatio.Tall]: {width: 750, height: 1000},
@ -33,7 +30,7 @@ export function Component({
onSelect,
}: {
uri: string
onSelect: (img?: PickedMedia) => void
onSelect: (img?: Image) => void
}) {
const store = useStores()
const pal = usePalette('default')
@ -52,7 +49,6 @@ export function Component({
if (canvas) {
const dataUri = canvas.toDataURL('image/jpeg')
onSelect({
mediaType: 'photo',
path: dataUri,
mime: 'image/jpeg',
size: getDataUriSize(dataUri),

View file

@ -4,12 +4,7 @@ import Svg, {Circle, Path} from 'react-native-svg'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {IconProp} from '@fortawesome/fontawesome-svg-core'
import {HighPriorityImage} from 'view/com/util/images/Image'
import {
openCamera,
openCropper,
openPicker,
PickedMedia,
} from '../../../lib/media/picker'
import {openCamera, openCropper, openPicker} from '../../../lib/media/picker'
import {
usePhotoLibraryPermission,
useCameraPermission,
@ -19,6 +14,7 @@ import {colors} from 'lib/styles'
import {DropdownButton} from './forms/DropdownButton'
import {usePalette} from 'lib/hooks/usePalette'
import {isWeb} from 'platform/detection'
import {Image as RNImage} from 'react-native-image-crop-picker'
function DefaultAvatar({size}: {size: number}) {
return (
@ -50,7 +46,7 @@ export function UserAvatar({
size: number
avatar?: string | null
hasWarning?: boolean
onSelectNewAvatar?: (img: PickedMedia | null) => void
onSelectNewAvatar?: (img: RNImage | null) => void
}) {
const store = useStores()
const pal = usePalette('default')
@ -68,7 +64,6 @@ export function UserAvatar({
}
onSelectNewAvatar?.(
await openCamera(store, {
mediaType: 'photo',
width: 1000,
height: 1000,
cropperCircleOverlay: true,
@ -84,9 +79,8 @@ export function UserAvatar({
if (!(await requestPhotoAccessIfNeeded())) {
return
}
const items = await openPicker(store, {
mediaType: 'photo',
})
const items = await openPicker(store)
onSelectNewAvatar?.(
await openCropper(store, {
mediaType: 'photo',

View file

@ -4,12 +4,8 @@ import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {IconProp} from '@fortawesome/fontawesome-svg-core'
import {Image} from 'expo-image'
import {colors} from 'lib/styles'
import {
openCamera,
openCropper,
openPicker,
PickedMedia,
} from '../../../lib/media/picker'
import {openCamera, openCropper, openPicker} from '../../../lib/media/picker'
import {Image as TImage} from 'lib/media/types'
import {useStores} from 'state/index'
import {
usePhotoLibraryPermission,
@ -24,7 +20,7 @@ export function UserBanner({
onSelectNewBanner,
}: {
banner?: string | null
onSelectNewBanner?: (img: PickedMedia | null) => void
onSelectNewBanner?: (img: TImage | null) => void
}) {
const store = useStores()
const pal = usePalette('default')
@ -42,7 +38,6 @@ export function UserBanner({
}
onSelectNewBanner?.(
await openCamera(store, {
mediaType: 'photo',
// compressImageMaxWidth: 3000, TODO needed?
width: 3000,
// compressImageMaxHeight: 1000, TODO needed?
@ -59,9 +54,7 @@ export function UserBanner({
if (!(await requestPhotoAccessIfNeeded())) {
return
}
const items = await openPicker(store, {
mediaType: 'photo',
})
const items = await openPicker(store)
onSelectNewBanner?.(
await openCropper(store, {
mediaType: 'photo',

View file

@ -1,4 +1,5 @@
import React from 'react'
import {Dimensions} from 'lib/media/types'
import React, {useState} from 'react'
import {
LayoutChangeEvent,
StyleProp,
@ -11,11 +12,6 @@ import {Image, ImageStyle} from 'expo-image'
export const DELAY_PRESS_IN = 500
interface Dim {
width: number
height: number
}
export type ImageLayoutGridType = 'two' | 'three' | 'four'
export function ImageLayoutGrid({
@ -33,7 +29,7 @@ export function ImageLayoutGrid({
onPressIn?: (index: number) => void
style?: StyleProp<ViewStyle>
}) {
const [containerInfo, setContainerInfo] = React.useState<Dim | undefined>()
const [containerInfo, setContainerInfo] = useState<Dimensions | undefined>()
const onLayout = (evt: LayoutChangeEvent) => {
setContainerInfo({
@ -71,7 +67,7 @@ function ImageLayoutGridInner({
onPress?: (index: number) => void
onLongPress?: (index: number) => void
onPressIn?: (index: number) => void
containerInfo: Dim
containerInfo: Dimensions
}) {
const size1 = React.useMemo<ImageStyle>(() => {
if (type === 'three') {