Break out the web/native image picking code and make some progress on the web version

zio/stable
Paul Frazee 2023-01-27 15:51:24 -06:00
parent 0673129b20
commit 7916b26aad
21 changed files with 738 additions and 138 deletions

View File

@ -25,6 +25,7 @@
"@fortawesome/react-native-fontawesome": "^0.3.0", "@fortawesome/react-native-fontawesome": "^0.3.0",
"@gorhom/bottom-sheet": "^4", "@gorhom/bottom-sheet": "^4",
"@mattermost/react-native-paste-input": "^0.6.0", "@mattermost/react-native-paste-input": "^0.6.0",
"@miblanchard/react-native-slider": "^2.2.0",
"@notifee/react-native": "^7.4.0", "@notifee/react-native": "^7.4.0",
"@react-native-async-storage/async-storage": "^1.17.6", "@react-native-async-storage/async-storage": "^1.17.6",
"@react-native-camera-roll/camera-roll": "^5.2.2", "@react-native-camera-roll/camera-roll": "^5.2.2",
@ -42,6 +43,7 @@
"mobx": "^6.6.1", "mobx": "^6.6.1",
"mobx-react-lite": "^3.4.0", "mobx-react-lite": "^3.4.0",
"react": "18.2.0", "react": "18.2.0",
"react-avatar-editor": "^13.0.0",
"react-circular-progressbar": "^2.1.0", "react-circular-progressbar": "^2.1.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-native": "0.71.0", "react-native": "0.71.0",
@ -84,6 +86,7 @@
"@types/jest": "^26.0.23", "@types/jest": "^26.0.23",
"@types/lodash.chunk": "^4.2.7", "@types/lodash.chunk": "^4.2.7",
"@types/lodash.omit": "^4.5.7", "@types/lodash.omit": "^4.5.7",
"@types/react-avatar-editor": "^13.0.0",
"@types/react-native": "^0.67.3", "@types/react-native": "^0.67.3",
"@types/react-test-renderer": "^17.0.1", "@types/react-test-renderer": "^17.0.1",
"@typescript-eslint/eslint-plugin": "^5.48.2", "@typescript-eslint/eslint-plugin": "^5.48.2",

View File

@ -13,8 +13,7 @@
#app-root { display:flex; height:100%; } #app-root { display:flex; height:100%; }
/* Remove focus state on inputs */ /* Remove focus state on inputs */
input:focus, *:focus {
textarea:focus {
outline: 0; outline: 0;
} }
</style> </style>

View File

@ -1,5 +1,5 @@
import {makeAutoObservable, runInAction} from 'mobx' import {makeAutoObservable, runInAction} from 'mobx'
import {Image as PickedImage} from '../../view/com/util/images/ImageCropPicker' import {Image as PickedImage} from '../../view/com/util/images/image-crop-picker/ImageCropPicker'
import { import {
AppBskyActorGetProfile as GetProfile, AppBskyActorGetProfile as GetProfile,
AppBskyActorProfile as Profile, AppBskyActorProfile as Profile,

View File

@ -1,6 +1,7 @@
import {makeAutoObservable} from 'mobx' import {makeAutoObservable} from 'mobx'
import {ProfileViewModel} from './profile-view' import {ProfileViewModel} from './profile-view'
import {isObj, hasProp} from '../lib/type-guards' import {isObj, hasProp} from '../lib/type-guards'
import {PickedMedia} from '../../view/com/util/images/image-crop-picker/types'
export class ConfirmModal { export class ConfirmModal {
name = 'confirm' name = 'confirm'
@ -52,6 +53,17 @@ export class ReportAccountModal {
} }
} }
export class CropImageModal {
name = 'crop-image'
constructor(
public uri: string,
public onSelect: (img?: PickedMedia) => void,
) {
makeAutoObservable(this)
}
}
interface LightboxModel {} interface LightboxModel {}
export class ProfileImageLightbox implements LightboxModel { export class ProfileImageLightbox implements LightboxModel {
@ -98,6 +110,7 @@ export class ShellUiModel {
| ServerInputModal | ServerInputModal
| ReportPostModal | ReportPostModal
| ReportAccountModal | ReportAccountModal
| CropImageModal
| undefined | undefined
isLightboxActive = false isLightboxActive = false
activeLightbox: ProfileImageLightbox | ImagesLightbox | undefined activeLightbox: ProfileImageLightbox | ImagesLightbox | undefined
@ -140,7 +153,8 @@ export class ShellUiModel {
| EditProfileModal | EditProfileModal
| ServerInputModal | ServerInputModal
| ReportPostModal | ReportPostModal
| ReportAccountModal, | ReportAccountModal
| CropImageModal,
) { ) {
this.isModalActive = true this.isModalActive = true
this.activeModal = modal this.activeModal = modal

View File

@ -16,14 +16,9 @@ export class UserLocalPhotosModel {
} }
async setup() { async setup() {
await this._getPhotos() const r = await CameraRoll.getPhotos({first: 20})
} runInAction(() => {
this.photos = r.edges
private async _getPhotos() {
CameraRoll.getPhotos({first: 20}).then(r => {
runInAction(() => {
this.photos = r.edges
})
}) })
} }
} }

View File

@ -37,8 +37,7 @@ import {
} from '../../../lib/strings' } from '../../../lib/strings'
import {getLinkMeta} from '../../../lib/link-meta' import {getLinkMeta} from '../../../lib/link-meta'
import {downloadAndResize} from '../../../lib/images' import {downloadAndResize} from '../../../lib/images'
import {UserLocalPhotosModel} from '../../../state/models/user-local-photos' import {PhotoCarouselPicker, cropPhoto} from './photos/PhotoCarouselPicker'
import {PhotoCarouselPicker, cropPhoto} from './PhotoCarouselPicker'
import {SelectedPhoto} from './SelectedPhoto' import {SelectedPhoto} from './SelectedPhoto'
import {usePalette} from '../../lib/hooks/usePalette' import {usePalette} from '../../lib/hooks/usePalette'
@ -77,10 +76,6 @@ export const ComposePost = observer(function ComposePost({
() => new UserAutocompleteViewModel(store), () => new UserAutocompleteViewModel(store),
[store], [store],
) )
const localPhotos = React.useMemo<UserLocalPhotosModel>(
() => new UserLocalPhotosModel(store),
[store],
)
// HACK // HACK
// there's a bug with @mattermost/react-native-paste-input where if the input // there's a bug with @mattermost/react-native-paste-input where if the input
@ -95,8 +90,7 @@ export const ComposePost = observer(function ComposePost({
// initial setup // initial setup
useEffect(() => { useEffect(() => {
autocompleteView.setup() autocompleteView.setup()
localPhotos.setup() }, [autocompleteView])
}, [autocompleteView, localPhotos])
// external link metadata-fetch flow // external link metadata-fetch flow
useEffect(() => { useEffect(() => {
@ -220,7 +214,7 @@ export const ComposePost = observer(function ComposePost({
} }
const imgUri = uris.find(uri => /\.(jpe?g|png)$/.test(uri)) const imgUri = uris.find(uri => /\.(jpe?g|png)$/.test(uri))
if (imgUri) { if (imgUri) {
const finalImgPath = await cropPhoto(imgUri) const finalImgPath = await cropPhoto(store, imgUri)
onSelectPhotos([...selectedPhotos, finalImgPath]) onSelectPhotos([...selectedPhotos, finalImgPath])
} }
} }
@ -412,15 +406,12 @@ export const ComposePost = observer(function ComposePost({
/> />
)} )}
</ScrollView> </ScrollView>
{isSelectingPhotos && {isSelectingPhotos && selectedPhotos.length < 4 && (
localPhotos.photos != null && <PhotoCarouselPicker
selectedPhotos.length < 4 && ( selectedPhotos={selectedPhotos}
<PhotoCarouselPicker onSelectPhotos={onSelectPhotos}
selectedPhotos={selectedPhotos} />
onSelectPhotos={onSelectPhotos} )}
localPhotos={localPhotos}
/>
)}
<View style={[pal.border, styles.bottomBar]}> <View style={[pal.border, styles.bottomBar]}>
<TouchableOpacity <TouchableOpacity
testID="composerSelectPhotosButton" testID="composerSelectPhotosButton"

View File

@ -8,14 +8,14 @@ import {
openPicker, openPicker,
openCamera, openCamera,
openCropper, openCropper,
} from '../util/images/ImageCropPicker' } from '../../util/images/image-crop-picker/ImageCropPicker'
import { import {
UserLocalPhotosModel, UserLocalPhotosModel,
PhotoIdentifier, PhotoIdentifier,
} from '../../../state/models/user-local-photos' } from '../../../../state/models/user-local-photos'
import {compressIfNeeded, scaleDownDimensions} from '../../../lib/images' import {compressIfNeeded, scaleDownDimensions} from '../../../../lib/images'
import {usePalette} from '../../lib/hooks/usePalette' import {usePalette} from '../../../lib/hooks/usePalette'
import {useStores} from '../../../state' import {useStores, RootStoreModel} from '../../../../state'
const MAX_WIDTH = 1000 const MAX_WIDTH = 1000
const MAX_HEIGHT = 1000 const MAX_HEIGHT = 1000
@ -25,11 +25,10 @@ const IMAGE_PARAMS = {
width: 1000, width: 1000,
height: 1000, height: 1000,
freeStyleCropEnabled: true, freeStyleCropEnabled: true,
forceJpg: true, // ios only
compressImageQuality: 1.0,
} }
export async function cropPhoto( export async function cropPhoto(
store: RootStoreModel,
path: string, path: string,
imgWidth = MAX_WIDTH, imgWidth = MAX_WIDTH,
imgHeight = MAX_HEIGHT, imgHeight = MAX_HEIGHT,
@ -40,10 +39,10 @@ export async function cropPhoto(
{width: imgWidth, height: imgHeight}, {width: imgWidth, height: imgHeight},
{width: MAX_WIDTH, height: MAX_HEIGHT}, {width: MAX_WIDTH, height: MAX_HEIGHT},
) )
const cropperRes = await openCropper({ const cropperRes = await openCropper(store, {
mediaType: 'photo', mediaType: 'photo',
path, path,
...IMAGE_PARAMS, freeStyleCropEnabled: true,
width, width,
height, height,
}) })
@ -54,19 +53,30 @@ export async function cropPhoto(
export const PhotoCarouselPicker = ({ export const PhotoCarouselPicker = ({
selectedPhotos, selectedPhotos,
onSelectPhotos, onSelectPhotos,
localPhotos,
}: { }: {
selectedPhotos: string[] selectedPhotos: string[]
onSelectPhotos: (v: string[]) => void onSelectPhotos: (v: string[]) => void
localPhotos: UserLocalPhotosModel
}) => { }) => {
const pal = usePalette('default') const pal = usePalette('default')
const store = useStores() const store = useStores()
const [localPhotos, setLocalPhotos] = React.useState<
UserLocalPhotosModel | undefined
>(undefined)
// initial setup
React.useEffect(() => {
const photos = new UserLocalPhotosModel(store)
photos.setup().then(() => {
if (photos.photos) {
setLocalPhotos(photos)
}
})
}, [store])
const handleOpenCamera = useCallback(async () => { const handleOpenCamera = useCallback(async () => {
try { try {
const cameraRes = await openCamera({ const cameraRes = await openCamera(store, {
mediaType: 'photo', mediaType: 'photo',
cropping: true,
...IMAGE_PARAMS, ...IMAGE_PARAMS,
}) })
const img = await compressIfNeeded(cameraRes, MAX_SIZE) const img = await compressIfNeeded(cameraRes, MAX_SIZE)
@ -75,12 +85,13 @@ export const PhotoCarouselPicker = ({
// ignore // ignore
store.log.warn('Error using camera', err) store.log.warn('Error using camera', err)
} }
}, [store.log, selectedPhotos, onSelectPhotos]) }, [store, selectedPhotos, onSelectPhotos])
const handleSelectPhoto = useCallback( const handleSelectPhoto = useCallback(
async (item: PhotoIdentifier) => { async (item: PhotoIdentifier) => {
try { try {
const imgPath = await cropPhoto( const imgPath = await cropPhoto(
store,
item.node.image.uri, item.node.image.uri,
item.node.image.width, item.node.image.width,
item.node.image.height, item.node.image.height,
@ -91,11 +102,11 @@ export const PhotoCarouselPicker = ({
store.log.warn('Error selecting photo', err) store.log.warn('Error selecting photo', err)
} }
}, },
[store.log, selectedPhotos, onSelectPhotos], [store, selectedPhotos, onSelectPhotos],
) )
const handleOpenGallery = useCallback(() => { const handleOpenGallery = useCallback(() => {
openPicker({ openPicker(store, {
multiple: true, multiple: true,
maxFiles: 4 - selectedPhotos.length, maxFiles: 4 - selectedPhotos.length,
mediaType: 'photo', mediaType: 'photo',
@ -109,10 +120,10 @@ export const PhotoCarouselPicker = ({
{width: image.width, height: image.height}, {width: image.width, height: image.height},
{width: MAX_WIDTH, height: MAX_HEIGHT}, {width: MAX_WIDTH, height: MAX_HEIGHT},
) )
const cropperRes = await openCropper({ const cropperRes = await openCropper(store, {
mediaType: 'photo', mediaType: 'photo',
path: image.path, path: image.path,
...IMAGE_PARAMS, freeStyleCropEnabled: true,
width, width,
height, height,
}) })
@ -121,7 +132,7 @@ export const PhotoCarouselPicker = ({
} }
onSelectPhotos([...selectedPhotos, ...result]) onSelectPhotos([...selectedPhotos, ...result])
}) })
}, [selectedPhotos, onSelectPhotos]) }, [store, selectedPhotos, onSelectPhotos])
return ( return (
<ScrollView <ScrollView
@ -150,15 +161,16 @@ export const PhotoCarouselPicker = ({
size={24} size={24}
/> />
</TouchableOpacity> </TouchableOpacity>
{localPhotos.photos.map((item: PhotoIdentifier, index: number) => ( {localPhotos != null &&
<TouchableOpacity localPhotos.photos.map((item: PhotoIdentifier, index: number) => (
testID="openSelectPhotoButton" <TouchableOpacity
key={`local-image-${index}`} testID="openSelectPhotoButton"
style={[pal.border, styles.photoButton]} key={`local-image-${index}`}
onPress={() => handleSelectPhoto(item)}> style={[pal.border, styles.photoButton]}
<Image style={styles.photo} source={{uri: item.node.image.uri}} /> onPress={() => handleSelectPhoto(item)}>
</TouchableOpacity> <Image style={styles.photo} source={{uri: item.node.image.uri}} />
))} </TouchableOpacity>
))}
</ScrollView> </ScrollView>
) )
} }

View File

@ -0,0 +1,158 @@
import React, {useCallback} from 'react'
import {StyleSheet, TouchableOpacity, ScrollView} from 'react-native'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {
openPicker,
openCamera,
openCropper,
} from '../../util/images/image-crop-picker/ImageCropPicker'
import {compressIfNeeded, scaleDownDimensions} from '../../../../lib/images'
import {usePalette} from '../../../lib/hooks/usePalette'
import {useStores, RootStoreModel} from '../../../../state'
const MAX_WIDTH = 1000
const MAX_HEIGHT = 1000
const MAX_SIZE = 300000
const IMAGE_PARAMS = {
width: 1000,
height: 1000,
freeStyleCropEnabled: true,
}
export async function cropPhoto(
store: RootStoreModel,
path: string,
imgWidth = MAX_WIDTH,
imgHeight = MAX_HEIGHT,
) {
// choose target dimensions based on the original
// this causes the photo cropper to start with the full image "selected"
const {width, height} = scaleDownDimensions(
{width: imgWidth, height: imgHeight},
{width: MAX_WIDTH, height: MAX_HEIGHT},
)
const cropperRes = await openCropper(store, {
mediaType: 'photo',
path,
freeStyleCropEnabled: true,
width,
height,
})
const img = await compressIfNeeded(cropperRes, MAX_SIZE)
return img.path
}
export const PhotoCarouselPicker = ({
selectedPhotos,
onSelectPhotos,
}: {
selectedPhotos: string[]
onSelectPhotos: (v: string[]) => void
}) => {
const pal = usePalette('default')
const store = useStores()
const handleOpenCamera = useCallback(async () => {
try {
const cameraRes = await openCamera(store, {
mediaType: 'photo',
...IMAGE_PARAMS,
})
const img = await compressIfNeeded(cameraRes, MAX_SIZE)
onSelectPhotos([...selectedPhotos, img.path])
} catch (err: any) {
// ignore
store.log.warn('Error using camera', err)
}
}, [store, selectedPhotos, onSelectPhotos])
const handleOpenGallery = useCallback(() => {
openPicker(store, {
multiple: true,
maxFiles: 4 - selectedPhotos.length,
mediaType: 'photo',
}).then(async items => {
const result = []
for (const image of items) {
// choose target dimensions based on the original
// this causes the photo cropper to start with the full image "selected"
const {width, height} = scaleDownDimensions(
{width: image.width, height: image.height},
{width: MAX_WIDTH, height: MAX_HEIGHT},
)
const cropperRes = await openCropper(store, {
mediaType: 'photo',
path: image.path,
freeStyleCropEnabled: true,
width,
height,
})
const finalImg = await compressIfNeeded(cropperRes, MAX_SIZE)
result.push(finalImg.path)
}
onSelectPhotos([...selectedPhotos, ...result])
})
}, [store, selectedPhotos, onSelectPhotos])
return (
<ScrollView
testID="photoCarouselPickerView"
horizontal
style={[pal.view, styles.photosContainer]}
keyboardShouldPersistTaps="always"
showsHorizontalScrollIndicator={false}>
<TouchableOpacity
testID="openCameraButton"
style={[styles.galleryButton, pal.border, styles.photo]}
onPress={handleOpenCamera}>
<FontAwesomeIcon
icon="camera"
size={24}
style={pal.link as FontAwesomeIconStyle}
/>
</TouchableOpacity>
<TouchableOpacity
testID="openGalleryButton"
style={[styles.galleryButton, pal.border, styles.photo]}
onPress={handleOpenGallery}>
<FontAwesomeIcon
icon="image"
style={pal.link as FontAwesomeIconStyle}
size={24}
/>
</TouchableOpacity>
</ScrollView>
)
}
const styles = StyleSheet.create({
photosContainer: {
width: '100%',
maxHeight: 96,
padding: 8,
overflow: 'hidden',
},
galleryButton: {
borderWidth: 1,
alignItems: 'center',
justifyContent: 'center',
},
photoButton: {
width: 75,
height: 75,
marginRight: 8,
borderWidth: 1,
borderRadius: 16,
},
photo: {
width: 75,
height: 75,
marginRight: 8,
borderRadius: 16,
},
})

View File

@ -8,7 +8,7 @@ import {
} from 'react-native' } from 'react-native'
import LinearGradient from 'react-native-linear-gradient' import LinearGradient from 'react-native-linear-gradient'
import {ScrollView, TextInput} from './util' import {ScrollView, TextInput} from './util'
import {Image as PickedImage} from '../util/images/ImageCropPicker' import {PickedMedia} from '../util/images/image-crop-picker/ImageCropPicker'
import {Text} from '../util/text/Text' import {Text} from '../util/text/Text'
import {ErrorMessage} from '../util/error/ErrorMessage' import {ErrorMessage} from '../util/error/ErrorMessage'
import {useStores} from '../../../state' import {useStores} from '../../../state'
@ -48,12 +48,12 @@ export function Component({
const [userAvatar, setUserAvatar] = useState<string | undefined>( const [userAvatar, setUserAvatar] = useState<string | undefined>(
profileView.avatar, profileView.avatar,
) )
const [newUserBanner, setNewUserBanner] = useState<PickedImage | undefined>() const [newUserBanner, setNewUserBanner] = useState<PickedMedia | undefined>()
const [newUserAvatar, setNewUserAvatar] = useState<PickedImage | undefined>() const [newUserAvatar, setNewUserAvatar] = useState<PickedMedia | undefined>()
const onPressCancel = () => { const onPressCancel = () => {
store.shell.closeModal() store.shell.closeModal()
} }
const onSelectNewAvatar = async (img: PickedImage) => { const onSelectNewAvatar = async (img: PickedMedia) => {
try { try {
const finalImg = await compressIfNeeded(img, 300000) const finalImg = await compressIfNeeded(img, 300000)
setNewUserAvatar(finalImg) setNewUserAvatar(finalImg)
@ -62,7 +62,7 @@ export function Component({
setError(e.message || e.toString()) setError(e.message || e.toString())
} }
} }
const onSelectNewBanner = async (img: PickedImage) => { const onSelectNewBanner = async (img: PickedMedia) => {
try { try {
const finalImg = await compressIfNeeded(img, 500000) const finalImg = await compressIfNeeded(img, 500000)
setNewUserBanner(finalImg) setNewUserBanner(finalImg)

View File

@ -11,6 +11,7 @@ import * as EditProfileModal from './EditProfile'
import * as ServerInputModal from './ServerInput' import * as ServerInputModal from './ServerInput'
import * as ReportPostModal from './ReportPost' import * as ReportPostModal from './ReportPost'
import * as ReportAccountModal from './ReportAccount' import * as ReportAccountModal from './ReportAccount'
import * as CropImageModal from './crop-image/CropImage.web'
export const Modal = observer(function Modal() { export const Modal = observer(function Modal() {
const store = useStores() const store = useStores()
@ -50,6 +51,12 @@ export const Modal = observer(function Modal() {
element = <ReportPostModal.Component /> element = <ReportPostModal.Component />
} else if (store.shell.activeModal?.name === 'report-account') { } else if (store.shell.activeModal?.name === 'report-account') {
element = <ReportAccountModal.Component /> element = <ReportAccountModal.Component />
} else if (store.shell.activeModal?.name === 'crop-image') {
element = (
<CropImageModal.Component
{...(store.shell.activeModal as models.CropImageModal)}
/>
)
} else { } else {
return null return null
} }

View File

@ -0,0 +1,11 @@
/**
* NOTE
* This modal is used only in the web build
* Native uses a third-party library
*/
export const snapPoints = ['0%']
export function Component() {
return null
}

View File

@ -0,0 +1,164 @@
import React from 'react'
import {StyleSheet, TouchableOpacity, View} from 'react-native'
import ImageEditor from 'react-avatar-editor'
import {Slider} from '@miblanchard/react-native-slider'
import LinearGradient from 'react-native-linear-gradient'
import {Text} from '../../util/text/Text'
import {PickedMedia} from '../../util/images/image-crop-picker/types'
import {s, gradients} from '../../../lib/styles'
import {useStores} from '../../../../state'
import {usePalette} from '../../../lib/hooks/usePalette'
import {SquareIcon, RectWideIcon, RectTallIcon} from '../../../lib/icons'
enum AspectRatio {
Square = 'square',
Wide = 'wide',
Tall = 'tall',
}
interface Dim {
width: number
height: number
}
const DIMS: Record<string, Dim> = {
[AspectRatio.Square]: {width: 1000, height: 1000},
[AspectRatio.Wide]: {width: 1000, height: 750},
[AspectRatio.Tall]: {width: 750, height: 1000},
}
export const snapPoints = ['0%']
export function Component({
uri,
onSelect,
}: {
uri: string
onSelect: (img?: PickedMedia) => void
}) {
const store = useStores()
const pal = usePalette('default')
const [as, setAs] = React.useState<AspectRatio>(AspectRatio.Square)
const [scale, setScale] = React.useState<number>(1)
const doSetAs = (v: AspectRatio) => () => setAs(v)
const onPressCancel = () => {
onSelect(undefined)
store.shell.closeModal()
}
const onPressDone = () => {
console.log('TODO')
onSelect(undefined) // TODO
store.shell.closeModal()
}
let cropperStyle
if (as === AspectRatio.Square) {
cropperStyle = styles.cropperSquare
} else if (as === AspectRatio.Wide) {
cropperStyle = styles.cropperWide
} else if (as === AspectRatio.Tall) {
cropperStyle = styles.cropperTall
}
return (
<View>
<View style={[styles.cropper, cropperStyle]}>
<ImageEditor
style={styles.imageEditor}
image={uri}
width={DIMS[as].width}
height={DIMS[as].height}
scale={scale}
/>
</View>
<View style={styles.ctrls}>
<Slider
value={scale}
onValueChange={(v: number | number[]) =>
setScale(Array.isArray(v) ? v[0] : v)
}
minimumValue={1}
maximumValue={3}
containerStyle={styles.slider}
/>
<TouchableOpacity onPress={doSetAs(AspectRatio.Wide)}>
<RectWideIcon
size={24}
style={as === AspectRatio.Wide ? s.blue3 : undefined}
/>
</TouchableOpacity>
<TouchableOpacity onPress={doSetAs(AspectRatio.Tall)}>
<RectTallIcon
size={24}
style={as === AspectRatio.Tall ? s.blue3 : undefined}
/>
</TouchableOpacity>
<TouchableOpacity onPress={doSetAs(AspectRatio.Square)}>
<SquareIcon
size={24}
style={as === AspectRatio.Square ? s.blue3 : undefined}
/>
</TouchableOpacity>
</View>
<View style={styles.btns}>
<TouchableOpacity onPress={onPressCancel}>
<Text type="xl" style={pal.link}>
Cancel
</Text>
</TouchableOpacity>
<View style={s.flex1} />
<TouchableOpacity onPress={onPressDone}>
<LinearGradient
colors={[gradients.blueLight.start, gradients.blueLight.end]}
start={{x: 0, y: 0}}
end={{x: 1, y: 1}}
style={[styles.btn]}>
<Text type="xl-medium" style={s.white}>
Done
</Text>
</LinearGradient>
</TouchableOpacity>
</View>
</View>
)
}
const styles = StyleSheet.create({
cropper: {
marginLeft: 'auto',
marginRight: 'auto',
},
cropperSquare: {
width: 400,
height: 400,
},
cropperWide: {
width: 400,
height: 300,
},
cropperTall: {
width: 300,
height: 400,
},
imageEditor: {
maxWidth: '100%',
},
ctrls: {
flexDirection: 'row',
alignItems: 'center',
marginTop: 10,
},
slider: {
flex: 1,
marginRight: 10,
},
btns: {
flexDirection: 'row',
alignItems: 'center',
marginTop: 10,
},
btn: {
borderRadius: 4,
paddingVertical: 8,
paddingHorizontal: 24,
},
})

View File

@ -6,8 +6,9 @@ import {
openCamera, openCamera,
openCropper, openCropper,
openPicker, openPicker,
Image as PickedImage, PickedMedia,
} from './images/ImageCropPicker' } from './images/image-crop-picker/ImageCropPicker'
import {useStores} from '../../../state'
import {colors, gradients} from '../../lib/styles' import {colors, gradients} from '../../lib/styles'
export function UserAvatar({ export function UserAvatar({
@ -21,8 +22,9 @@ export function UserAvatar({
handle: string handle: string
displayName: string | undefined displayName: string | undefined
avatar?: string | null avatar?: string | null
onSelectNewAvatar?: (img: PickedImage) => void onSelectNewAvatar?: (img: PickedMedia) => void
}) { }) {
const store = useStores()
const initials = getInitials(displayName || handle) const initials = getInitials(displayName || handle)
const handleEditAvatar = useCallback(() => { const handleEditAvatar = useCallback(() => {
@ -30,37 +32,32 @@ export function UserAvatar({
{ {
text: 'Take a new photo', text: 'Take a new photo',
onPress: () => { onPress: () => {
openCamera({ openCamera(store, {
mediaType: 'photo', mediaType: 'photo',
cropping: true,
width: 1000, width: 1000,
height: 1000, height: 1000,
cropperCircleOverlay: true, cropperCircleOverlay: true,
forceJpg: true, // ios only
compressImageQuality: 1,
}).then(onSelectNewAvatar) }).then(onSelectNewAvatar)
}, },
}, },
{ {
text: 'Select from gallery', text: 'Select from gallery',
onPress: () => { onPress: () => {
openPicker({ openPicker(store, {
mediaType: 'photo', mediaType: 'photo',
}).then(async item => { }).then(async items => {
await openCropper({ await openCropper(store, {
mediaType: 'photo', mediaType: 'photo',
path: item.path, path: items[0].path,
width: 1000, width: 1000,
height: 1000, height: 1000,
cropperCircleOverlay: true, cropperCircleOverlay: true,
forceJpg: true, // ios only
compressImageQuality: 1,
}).then(onSelectNewAvatar) }).then(onSelectNewAvatar)
}) })
}, },
}, },
]) ])
}, [onSelectNewAvatar]) }, [store, onSelectNewAvatar])
const renderSvg = (svgSize: number, svgInitials: string) => ( const renderSvg = (svgSize: number, svgInitials: string) => (
<Svg width={svgSize} height={svgSize} viewBox="0 0 100 100"> <Svg width={svgSize} height={svgSize} viewBox="0 0 100 100">

View File

@ -2,57 +2,56 @@ import React, {useCallback} from 'react'
import {StyleSheet, View, TouchableOpacity, Alert, Image} from 'react-native' import {StyleSheet, View, TouchableOpacity, Alert, Image} from 'react-native'
import Svg, {Rect, Defs, LinearGradient, Stop} from 'react-native-svg' import Svg, {Rect, Defs, LinearGradient, Stop} from 'react-native-svg'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {Image as PickedImage} from './images/ImageCropPicker'
import {colors, gradients} from '../../lib/styles' import {colors, gradients} from '../../lib/styles'
import {openCamera, openCropper, openPicker} from './images/ImageCropPicker' import {
openCamera,
openCropper,
openPicker,
PickedMedia,
} from './images/image-crop-picker/ImageCropPicker'
import {useStores} from '../../../state'
export function UserBanner({ export function UserBanner({
banner, banner,
onSelectNewBanner, onSelectNewBanner,
}: { }: {
banner?: string | null banner?: string | null
onSelectNewBanner?: (img: PickedImage) => void onSelectNewBanner?: (img: PickedMedia) => void
}) { }) {
const store = useStores()
const handleEditBanner = useCallback(() => { const handleEditBanner = useCallback(() => {
Alert.alert('Select upload method', '', [ Alert.alert('Select upload method', '', [
{ {
text: 'Take a new photo', text: 'Take a new photo',
onPress: () => { onPress: () => {
openCamera({ openCamera(store, {
mediaType: 'photo', mediaType: 'photo',
cropping: true, // compressImageMaxWidth: 3000, TODO needed?
compressImageMaxWidth: 3000,
width: 3000, width: 3000,
compressImageMaxHeight: 1000, // compressImageMaxHeight: 1000, TODO needed?
height: 1000, height: 1000,
forceJpg: true, // ios only
compressImageQuality: 1,
includeExif: true,
}).then(onSelectNewBanner) }).then(onSelectNewBanner)
}, },
}, },
{ {
text: 'Select from gallery', text: 'Select from gallery',
onPress: () => { onPress: () => {
openPicker({ openPicker(store, {
mediaType: 'photo', mediaType: 'photo',
}).then(async item => { }).then(async items => {
await openCropper({ await openCropper(store, {
mediaType: 'photo', mediaType: 'photo',
path: item.path, path: items[0].path,
compressImageMaxWidth: 3000, // compressImageMaxWidth: 3000, TODO needed?
width: 3000, width: 3000,
compressImageMaxHeight: 1000, // compressImageMaxHeight: 1000, TODO needed?
height: 1000, height: 1000,
forceJpg: true, // ios only
compressImageQuality: 1,
includeExif: true,
}).then(onSelectNewBanner) }).then(onSelectNewBanner)
}) })
}, },
}, },
]) ])
}, [onSelectNewBanner]) }, [store, onSelectNewBanner])
const renderSvg = () => ( const renderSvg = () => (
<Svg width="100%" height="150" viewBox="50 0 200 100"> <Svg width="100%" height="150" viewBox="50 0 200 100">

View File

@ -1,6 +0,0 @@
export {
openPicker,
openCamera,
openCropper,
} from 'react-native-image-crop-picker'
export type {Image} from 'react-native-image-crop-picker'

View File

@ -1,32 +0,0 @@
import type {
Image,
Video,
ImageOrVideo,
Options,
PossibleArray,
} from 'react-native-image-crop-picker'
export type {Image} from 'react-native-image-crop-picker'
type MediaType<O> = O extends {mediaType: 'photo'}
? Image
: O extends {mediaType: 'video'}
? Video
: ImageOrVideo
export async function openPicker<O extends Options>(
_options: O,
): Promise<PossibleArray<O, MediaType<O>>> {
// TODO
throw new Error('TODO')
}
export async function openCamera<O extends Options>(
_options: O,
): Promise<PossibleArray<O, MediaType<O>>> {
// TODO
throw new Error('TODO')
}
export async function openCropper(_options: Options): Promise<Image> {
// TODO
throw new Error('TODO')
}

View File

@ -0,0 +1,92 @@
import {
openPicker as openPickerFn,
openCamera as openCameraFn,
openCropper as openCropperFn,
ImageOrVideo,
} from 'react-native-image-crop-picker'
import {RootStoreModel} from '../../../../../state'
import {PickerOpts, CameraOpts, CropperOpts, PickedMedia} from './types'
export type {PickedMedia} from './types'
/**
* NOTE
* These methods all include the RootStoreModel as the first param
* because the web versions require it. The signatures have to remain
* equivalent between the different forms, but the store param is not
* used here.
* -prf
*/
export async function openPicker(
_store: RootStoreModel,
opts: PickerOpts,
): Promise<PickedMedia[]> {
const mediaType = opts.mediaType || 'photo'
const items = await openPickerFn({
mediaType,
multiple: opts.multiple,
maxFiles: opts.maxFiles,
})
const toMedia = (item: ImageOrVideo) => ({
mediaType,
path: item.path,
mime: item.mime,
size: item.size,
width: item.width,
height: item.height,
})
if (Array.isArray(items)) {
return items.map(toMedia)
}
return [toMedia(items)]
}
export async function openCamera(
_store: RootStoreModel,
opts: CameraOpts,
): Promise<PickedMedia> {
const mediaType = opts.mediaType || 'photo'
const item = await openCameraFn({
mediaType,
width: opts.width,
height: opts.height,
freeStyleCropEnabled: opts.freeStyleCropEnabled,
cropperCircleOverlay: opts.cropperCircleOverlay,
cropping: true,
forceJpg: true, // ios only
compressImageQuality: 1.0,
})
return {
mediaType,
path: item.path,
mime: item.mime,
size: item.size,
width: item.width,
height: item.height,
}
}
export async function openCropper(
_store: RootStoreModel,
opts: CropperOpts,
): Promise<PickedMedia> {
const mediaType = opts.mediaType || 'photo'
const item = await openCropperFn({
path: opts.path,
mediaType: opts.mediaType || 'photo',
width: opts.width,
height: opts.height,
freeStyleCropEnabled: opts.freeStyleCropEnabled,
cropperCircleOverlay: opts.cropperCircleOverlay,
forceJpg: true, // ios only
compressImageQuality: 1.0,
})
return {
mediaType,
path: item.path,
mime: item.mime,
size: item.size,
width: item.width,
height: item.height,
}
}

View File

@ -0,0 +1,75 @@
/// <reference lib="dom" />
import {CropImageModal} from '../../../../../state/models/shell-ui'
import {PickerOpts, CameraOpts, CropperOpts, PickedMedia} from './types'
export type {PickedMedia} from './types'
import {RootStoreModel} from '../../../../../state'
interface PickedFile {
uri: string
path: string
size: number
}
export async function openPicker(
store: RootStoreModel,
opts: PickerOpts,
): Promise<PickedMedia[] | PickedMedia> {
const res = await selectFile(opts)
return new Promise((resolve, reject) => {
store.shell.openModal(
new CropImageModal(res.uri, (img?: PickedMedia) => {
if (img) {
resolve(img)
} else {
reject(new Error('Canceled'))
}
}),
)
})
}
export async function openCamera(
_store: RootStoreModel,
opts: CameraOpts,
): Promise<PickedMedia> {
const mediaType = opts.mediaType || 'photo'
throw new Error('TODO')
}
export async function openCropper(
_store: RootStoreModel,
opts: CropperOpts,
): Promise<PickedMedia> {
const mediaType = opts.mediaType || 'photo'
throw new Error('TODO')
}
function selectFile(opts: PickerOpts): Promise<PickedFile> {
return new Promise((resolve, reject) => {
var input = document.createElement('input')
input.type = 'file'
input.accept = opts.mediaType === 'photo' ? 'image/*' : '*/*'
input.onchange = e => {
const target = e.target as HTMLInputElement
const file = target?.files?.[0]
if (!file) {
return reject(new Error('Canceled'))
}
var reader = new FileReader()
reader.readAsDataURL(file)
reader.onload = readerEvent => {
if (!readerEvent.target) {
return reject(new Error('Canceled'))
}
resolve({
uri: readerEvent.target.result as string,
path: file.name,
size: file.size,
})
}
}
input.click()
})
}

View File

@ -0,0 +1,31 @@
export interface PickerOpts {
mediaType?: 'photo'
multiple?: boolean
maxFiles?: number
}
export interface CameraOpts {
mediaType?: 'photo'
width: number
height: number
freeStyleCropEnabled?: boolean
cropperCircleOverlay?: boolean
}
export interface CropperOpts {
path: string
mediaType?: 'photo'
width: number
height: number
freeStyleCropEnabled?: boolean
cropperCircleOverlay?: boolean
}
export interface PickedMedia {
mediaType: 'photo'
path: string
mime: string
size: number
width: number
height: number
}

View File

@ -1,6 +1,6 @@
import React from 'react' import React from 'react'
import {StyleProp, TextStyle, ViewStyle} from 'react-native' import {StyleProp, TextStyle, ViewStyle} from 'react-native'
import Svg, {Path} from 'react-native-svg' import Svg, {Path, Rect} from 'react-native-svg'
export function GridIcon({ export function GridIcon({
style, style,
@ -458,3 +458,72 @@ export function CommentBottomArrow({
</Svg> </Svg>
) )
} }
export function SquareIcon({
style,
size,
strokeWidth = 1.3,
}: {
style?: StyleProp<TextStyle>
size?: string | number
strokeWidth?: number
}) {
return (
<Svg
fill="none"
viewBox="0 0 24 24"
strokeWidth={strokeWidth || 1}
stroke="currentColor"
width={size || 24}
height={size || 24}
style={style}>
<Rect x="6" y="6" width="12" height="12" strokeLinejoin="round" />
</Svg>
)
}
export function RectWideIcon({
style,
size,
strokeWidth = 1.3,
}: {
style?: StyleProp<TextStyle>
size?: string | number
strokeWidth?: number
}) {
return (
<Svg
fill="none"
viewBox="0 0 24 24"
strokeWidth={strokeWidth || 1}
stroke="currentColor"
width={size || 24}
height={size || 24}
style={style}>
<Rect x="4" y="6" width="16" height="12" strokeLinejoin="round" />
</Svg>
)
}
export function RectTallIcon({
style,
size,
strokeWidth = 1.3,
}: {
style?: StyleProp<TextStyle>
size?: string | number
strokeWidth?: number
}) {
return (
<Svg
fill="none"
viewBox="0 0 24 24"
strokeWidth={strokeWidth || 1}
stroke="currentColor"
width={size || 24}
height={size || 24}
style={style}>
<Rect x="6" y="4" width="12" height="16" strokeLinejoin="round" />
</Svg>
)
}

View File

@ -1065,7 +1065,7 @@
dependencies: dependencies:
"@babel/helper-plugin-utils" "^7.18.6" "@babel/helper-plugin-utils" "^7.18.6"
"@babel/plugin-transform-runtime@^7.0.0", "@babel/plugin-transform-runtime@^7.16.4": "@babel/plugin-transform-runtime@^7.0.0", "@babel/plugin-transform-runtime@^7.12.1", "@babel/plugin-transform-runtime@^7.16.4":
version "7.19.6" version "7.19.6"
resolved "https://registry.yarnpkg.com/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.19.6.tgz#9d2a9dbf4e12644d6f46e5e75bfbf02b5d6e9194" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.19.6.tgz#9d2a9dbf4e12644d6f46e5e75bfbf02b5d6e9194"
integrity sha512-PRH37lz4JU156lYFW1p8OxE5i7d6Sl/zV58ooyr+q1J1lnQPyg5tIiXlIwNVhJaY4W3TmOtdc8jqdXQcB1v5Yw== integrity sha512-PRH37lz4JU156lYFW1p8OxE5i7d6Sl/zV58ooyr+q1J1lnQPyg5tIiXlIwNVhJaY4W3TmOtdc8jqdXQcB1v5Yw==
@ -2088,6 +2088,11 @@
dependencies: dependencies:
semver "7.3.8" semver "7.3.8"
"@miblanchard/react-native-slider@^2.2.0":
version "2.2.0"
resolved "https://registry.yarnpkg.com/@miblanchard/react-native-slider/-/react-native-slider-2.2.0.tgz#5d03cf49516ad0a3b4011fbcad53cb379800832b"
integrity sha512-LepVGFVy6KtDVgMRIAAJJKQCXbcADkzK2R61t3LkD+IF2wG1J4I4KVo99GAsvU0EBKeCsjHPkR+6LGnB6xGzVA==
"@nicolo-ribaudo/eslint-scope-5-internals@5.1.1-v1": "@nicolo-ribaudo/eslint-scope-5-internals@5.1.1-v1":
version "5.1.1-v1" version "5.1.1-v1"
resolved "https://registry.yarnpkg.com/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz#dbf733a965ca47b1973177dc0bb6c889edcfb129" resolved "https://registry.yarnpkg.com/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz#dbf733a965ca47b1973177dc0bb6c889edcfb129"
@ -2900,6 +2905,13 @@
resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.4.tgz#cd667bcfdd025213aafb7ca5915a932590acdcdc" resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.4.tgz#cd667bcfdd025213aafb7ca5915a932590acdcdc"
integrity sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw== integrity sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==
"@types/react-avatar-editor@^13.0.0":
version "13.0.0"
resolved "https://registry.yarnpkg.com/@types/react-avatar-editor/-/react-avatar-editor-13.0.0.tgz#5963e16c931746c47e478d669dd72d388b427393"
integrity sha512-5ymOayy6mfT35xTqzni7UjXvCNEg8/pH4pI5RenITp9PBc02KGTYjSV1WboXiQDYSh5KomLT0ngBLEAIhV1QoQ==
dependencies:
"@types/react" "*"
"@types/react-native@^0.67.3": "@types/react-native@^0.67.3":
version "0.67.17" version "0.67.17"
resolved "https://registry.yarnpkg.com/@types/react-native/-/react-native-0.67.17.tgz#afebc3fff1d6314840c13b7936e17fa350eb7aae" resolved "https://registry.yarnpkg.com/@types/react-native/-/react-native-0.67.17.tgz#afebc3fff1d6314840c13b7936e17fa350eb7aae"
@ -2914,7 +2926,7 @@
dependencies: dependencies:
"@types/react" "^17" "@types/react" "^17"
"@types/react@^17": "@types/react@*", "@types/react@^17":
version "17.0.52" version "17.0.52"
resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.52.tgz#10d8b907b5c563ac014a541f289ae8eaa9bf2e9b" resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.52.tgz#10d8b907b5c563ac014a541f289ae8eaa9bf2e9b"
integrity sha512-vwk8QqVODi0VaZZpDXQCmEmiOuyjEFPY7Ttaw5vjM112LOq37yz1CDJGrRJwA1fYEq4Iitd5rnjd1yWAc/bT+A== integrity sha512-vwk8QqVODi0VaZZpDXQCmEmiOuyjEFPY7Ttaw5vjM112LOq37yz1CDJGrRJwA1fYEq4Iitd5rnjd1yWAc/bT+A==
@ -11162,6 +11174,15 @@ react-app-polyfill@^3.0.0:
regenerator-runtime "^0.13.9" regenerator-runtime "^0.13.9"
whatwg-fetch "^3.6.2" whatwg-fetch "^3.6.2"
react-avatar-editor@^13.0.0:
version "13.0.0"
resolved "https://registry.yarnpkg.com/react-avatar-editor/-/react-avatar-editor-13.0.0.tgz#55013625ee9ae715c1fe2dc553b8079994d8a5f2"
integrity sha512-0xw63MbRRQdDy7YI1IXU9+7tTFxYEFLV8CABvryYOGjZmXRTH2/UA0mafe57ns62uaEFX181kA4XlGlxCaeXKA==
dependencies:
"@babel/plugin-transform-runtime" "^7.12.1"
"@babel/runtime" "^7.12.5"
prop-types "^15.7.2"
react-circular-progressbar@^2.1.0: react-circular-progressbar@^2.1.0:
version "2.1.0" version "2.1.0"
resolved "https://registry.yarnpkg.com/react-circular-progressbar/-/react-circular-progressbar-2.1.0.tgz#99e5ae499c21de82223b498289e96f66adb8fa3a" resolved "https://registry.yarnpkg.com/react-circular-progressbar/-/react-circular-progressbar-2.1.0.tgz#99e5ae499c21de82223b498289e96f66adb8fa3a"