Break out the web/native image picking code and make some progress on the web version
parent
0673129b20
commit
7916b26aad
|
@ -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",
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
|
@ -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,
|
||||||
|
},
|
||||||
|
})
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
|
@ -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,
|
||||||
|
},
|
||||||
|
})
|
|
@ -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">
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -1,6 +0,0 @@
|
||||||
export {
|
|
||||||
openPicker,
|
|
||||||
openCamera,
|
|
||||||
openCropper,
|
|
||||||
} from 'react-native-image-crop-picker'
|
|
||||||
export type {Image} from 'react-native-image-crop-picker'
|
|
|
@ -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')
|
|
||||||
}
|
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
|
@ -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()
|
||||||
|
})
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
25
yarn.lock
25
yarn.lock
|
@ -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"
|
||||||
|
|
Loading…
Reference in New Issue