diff options
author | Paul Frazee <pfrazee@gmail.com> | 2023-01-27 15:51:24 -0600 |
---|---|---|
committer | Paul Frazee <pfrazee@gmail.com> | 2023-01-27 15:51:24 -0600 |
commit | 7916b26aadb7e003728d9dc653ab8b8deabf4076 (patch) | |
tree | 507d24512fd71c67d4fe49af4ae5f8746444cceb /src | |
parent | 0673129b2018c9db0f7c3fc3e2c3214150efcfb8 (diff) | |
download | voidsky-7916b26aadb7e003728d9dc653ab8b8deabf4076.tar.zst |
Break out the web/native image picking code and make some progress on the web version
Diffstat (limited to 'src')
18 files changed, 711 insertions, 134 deletions
diff --git a/src/state/models/profile-view.ts b/src/state/models/profile-view.ts index 3228c57e8..79882a562 100644 --- a/src/state/models/profile-view.ts +++ b/src/state/models/profile-view.ts @@ -1,5 +1,5 @@ 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 { AppBskyActorGetProfile as GetProfile, AppBskyActorProfile as Profile, diff --git a/src/state/models/shell-ui.ts b/src/state/models/shell-ui.ts index b84d6ece9..09ffd265a 100644 --- a/src/state/models/shell-ui.ts +++ b/src/state/models/shell-ui.ts @@ -1,6 +1,7 @@ import {makeAutoObservable} from 'mobx' import {ProfileViewModel} from './profile-view' import {isObj, hasProp} from '../lib/type-guards' +import {PickedMedia} from '../../view/com/util/images/image-crop-picker/types' export class ConfirmModal { 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 {} export class ProfileImageLightbox implements LightboxModel { @@ -98,6 +110,7 @@ export class ShellUiModel { | ServerInputModal | ReportPostModal | ReportAccountModal + | CropImageModal | undefined isLightboxActive = false activeLightbox: ProfileImageLightbox | ImagesLightbox | undefined @@ -140,7 +153,8 @@ export class ShellUiModel { | EditProfileModal | ServerInputModal | ReportPostModal - | ReportAccountModal, + | ReportAccountModal + | CropImageModal, ) { this.isModalActive = true this.activeModal = modal diff --git a/src/state/models/user-local-photos.ts b/src/state/models/user-local-photos.ts index 08b2b3901..b14e8a6a4 100644 --- a/src/state/models/user-local-photos.ts +++ b/src/state/models/user-local-photos.ts @@ -16,14 +16,9 @@ export class UserLocalPhotosModel { } async setup() { - await this._getPhotos() - } - - private async _getPhotos() { - CameraRoll.getPhotos({first: 20}).then(r => { - runInAction(() => { - this.photos = r.edges - }) + const r = await CameraRoll.getPhotos({first: 20}) + runInAction(() => { + this.photos = r.edges }) } } diff --git a/src/view/com/composer/ComposePost.tsx b/src/view/com/composer/ComposePost.tsx index 2f30a1cf4..1144b5e48 100644 --- a/src/view/com/composer/ComposePost.tsx +++ b/src/view/com/composer/ComposePost.tsx @@ -37,8 +37,7 @@ import { } from '../../../lib/strings' import {getLinkMeta} from '../../../lib/link-meta' import {downloadAndResize} from '../../../lib/images' -import {UserLocalPhotosModel} from '../../../state/models/user-local-photos' -import {PhotoCarouselPicker, cropPhoto} from './PhotoCarouselPicker' +import {PhotoCarouselPicker, cropPhoto} from './photos/PhotoCarouselPicker' import {SelectedPhoto} from './SelectedPhoto' import {usePalette} from '../../lib/hooks/usePalette' @@ -77,10 +76,6 @@ export const ComposePost = observer(function ComposePost({ () => new UserAutocompleteViewModel(store), [store], ) - const localPhotos = React.useMemo<UserLocalPhotosModel>( - () => new UserLocalPhotosModel(store), - [store], - ) // HACK // 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 useEffect(() => { autocompleteView.setup() - localPhotos.setup() - }, [autocompleteView, localPhotos]) + }, [autocompleteView]) // external link metadata-fetch flow useEffect(() => { @@ -220,7 +214,7 @@ export const ComposePost = observer(function ComposePost({ } const imgUri = uris.find(uri => /\.(jpe?g|png)$/.test(uri)) if (imgUri) { - const finalImgPath = await cropPhoto(imgUri) + const finalImgPath = await cropPhoto(store, imgUri) onSelectPhotos([...selectedPhotos, finalImgPath]) } } @@ -412,15 +406,12 @@ export const ComposePost = observer(function ComposePost({ /> )} </ScrollView> - {isSelectingPhotos && - localPhotos.photos != null && - selectedPhotos.length < 4 && ( - <PhotoCarouselPicker - selectedPhotos={selectedPhotos} - onSelectPhotos={onSelectPhotos} - localPhotos={localPhotos} - /> - )} + {isSelectingPhotos && selectedPhotos.length < 4 && ( + <PhotoCarouselPicker + selectedPhotos={selectedPhotos} + onSelectPhotos={onSelectPhotos} + /> + )} <View style={[pal.border, styles.bottomBar]}> <TouchableOpacity testID="composerSelectPhotosButton" diff --git a/src/view/com/composer/PhotoCarouselPicker.tsx b/src/view/com/composer/photos/PhotoCarouselPicker.tsx index eb5b4dcf2..7a5c9f65d 100644 --- a/src/view/com/composer/PhotoCarouselPicker.tsx +++ b/src/view/com/composer/photos/PhotoCarouselPicker.tsx @@ -8,14 +8,14 @@ import { openPicker, openCamera, openCropper, -} from '../util/images/ImageCropPicker' +} from '../../util/images/image-crop-picker/ImageCropPicker' import { UserLocalPhotosModel, PhotoIdentifier, -} from '../../../state/models/user-local-photos' -import {compressIfNeeded, scaleDownDimensions} from '../../../lib/images' -import {usePalette} from '../../lib/hooks/usePalette' -import {useStores} from '../../../state' +} from '../../../../state/models/user-local-photos' +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 @@ -25,11 +25,10 @@ const IMAGE_PARAMS = { width: 1000, height: 1000, freeStyleCropEnabled: true, - forceJpg: true, // ios only - compressImageQuality: 1.0, } export async function cropPhoto( + store: RootStoreModel, path: string, imgWidth = MAX_WIDTH, imgHeight = MAX_HEIGHT, @@ -40,10 +39,10 @@ export async function cropPhoto( {width: imgWidth, height: imgHeight}, {width: MAX_WIDTH, height: MAX_HEIGHT}, ) - const cropperRes = await openCropper({ + const cropperRes = await openCropper(store, { mediaType: 'photo', path, - ...IMAGE_PARAMS, + freeStyleCropEnabled: true, width, height, }) @@ -54,19 +53,30 @@ export async function cropPhoto( export const PhotoCarouselPicker = ({ selectedPhotos, onSelectPhotos, - localPhotos, }: { selectedPhotos: string[] onSelectPhotos: (v: string[]) => void - localPhotos: UserLocalPhotosModel }) => { const pal = usePalette('default') 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 () => { try { - const cameraRes = await openCamera({ + const cameraRes = await openCamera(store, { mediaType: 'photo', - cropping: true, ...IMAGE_PARAMS, }) const img = await compressIfNeeded(cameraRes, MAX_SIZE) @@ -75,12 +85,13 @@ export const PhotoCarouselPicker = ({ // ignore store.log.warn('Error using camera', err) } - }, [store.log, selectedPhotos, onSelectPhotos]) + }, [store, selectedPhotos, onSelectPhotos]) const handleSelectPhoto = useCallback( async (item: PhotoIdentifier) => { try { const imgPath = await cropPhoto( + store, item.node.image.uri, item.node.image.width, item.node.image.height, @@ -91,11 +102,11 @@ export const PhotoCarouselPicker = ({ store.log.warn('Error selecting photo', err) } }, - [store.log, selectedPhotos, onSelectPhotos], + [store, selectedPhotos, onSelectPhotos], ) const handleOpenGallery = useCallback(() => { - openPicker({ + openPicker(store, { multiple: true, maxFiles: 4 - selectedPhotos.length, mediaType: 'photo', @@ -109,10 +120,10 @@ export const PhotoCarouselPicker = ({ {width: image.width, height: image.height}, {width: MAX_WIDTH, height: MAX_HEIGHT}, ) - const cropperRes = await openCropper({ + const cropperRes = await openCropper(store, { mediaType: 'photo', path: image.path, - ...IMAGE_PARAMS, + freeStyleCropEnabled: true, width, height, }) @@ -121,7 +132,7 @@ export const PhotoCarouselPicker = ({ } onSelectPhotos([...selectedPhotos, ...result]) }) - }, [selectedPhotos, onSelectPhotos]) + }, [store, selectedPhotos, onSelectPhotos]) return ( <ScrollView @@ -150,15 +161,16 @@ export const PhotoCarouselPicker = ({ size={24} /> </TouchableOpacity> - {localPhotos.photos.map((item: PhotoIdentifier, index: number) => ( - <TouchableOpacity - testID="openSelectPhotoButton" - key={`local-image-${index}`} - style={[pal.border, styles.photoButton]} - onPress={() => handleSelectPhoto(item)}> - <Image style={styles.photo} source={{uri: item.node.image.uri}} /> - </TouchableOpacity> - ))} + {localPhotos != null && + localPhotos.photos.map((item: PhotoIdentifier, index: number) => ( + <TouchableOpacity + testID="openSelectPhotoButton" + key={`local-image-${index}`} + style={[pal.border, styles.photoButton]} + onPress={() => handleSelectPhoto(item)}> + <Image style={styles.photo} source={{uri: item.node.image.uri}} /> + </TouchableOpacity> + ))} </ScrollView> ) } diff --git a/src/view/com/composer/photos/PhotoCarouselPicker.web.tsx b/src/view/com/composer/photos/PhotoCarouselPicker.web.tsx new file mode 100644 index 000000000..bb2800026 --- /dev/null +++ b/src/view/com/composer/photos/PhotoCarouselPicker.web.tsx @@ -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, + }, +}) diff --git a/src/view/com/modals/EditProfile.tsx b/src/view/com/modals/EditProfile.tsx index 1c139e9bd..12b72a399 100644 --- a/src/view/com/modals/EditProfile.tsx +++ b/src/view/com/modals/EditProfile.tsx @@ -8,7 +8,7 @@ import { } from 'react-native' import LinearGradient from 'react-native-linear-gradient' 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 {ErrorMessage} from '../util/error/ErrorMessage' import {useStores} from '../../../state' @@ -48,12 +48,12 @@ export function Component({ const [userAvatar, setUserAvatar] = useState<string | undefined>( profileView.avatar, ) - const [newUserBanner, setNewUserBanner] = useState<PickedImage | undefined>() - const [newUserAvatar, setNewUserAvatar] = useState<PickedImage | undefined>() + const [newUserBanner, setNewUserBanner] = useState<PickedMedia | undefined>() + const [newUserAvatar, setNewUserAvatar] = useState<PickedMedia | undefined>() const onPressCancel = () => { store.shell.closeModal() } - const onSelectNewAvatar = async (img: PickedImage) => { + const onSelectNewAvatar = async (img: PickedMedia) => { try { const finalImg = await compressIfNeeded(img, 300000) setNewUserAvatar(finalImg) @@ -62,7 +62,7 @@ export function Component({ setError(e.message || e.toString()) } } - const onSelectNewBanner = async (img: PickedImage) => { + const onSelectNewBanner = async (img: PickedMedia) => { try { const finalImg = await compressIfNeeded(img, 500000) setNewUserBanner(finalImg) diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx index 25493312d..44ea95f07 100644 --- a/src/view/com/modals/Modal.web.tsx +++ b/src/view/com/modals/Modal.web.tsx @@ -11,6 +11,7 @@ import * as EditProfileModal from './EditProfile' import * as ServerInputModal from './ServerInput' import * as ReportPostModal from './ReportPost' import * as ReportAccountModal from './ReportAccount' +import * as CropImageModal from './crop-image/CropImage.web' export const Modal = observer(function Modal() { const store = useStores() @@ -50,6 +51,12 @@ export const Modal = observer(function Modal() { element = <ReportPostModal.Component /> } else if (store.shell.activeModal?.name === 'report-account') { element = <ReportAccountModal.Component /> + } else if (store.shell.activeModal?.name === 'crop-image') { + element = ( + <CropImageModal.Component + {...(store.shell.activeModal as models.CropImageModal)} + /> + ) } else { return null } diff --git a/src/view/com/modals/crop-image/CropImage.tsx b/src/view/com/modals/crop-image/CropImage.tsx new file mode 100644 index 000000000..9ac3f277f --- /dev/null +++ b/src/view/com/modals/crop-image/CropImage.tsx @@ -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 +} diff --git a/src/view/com/modals/crop-image/CropImage.web.tsx b/src/view/com/modals/crop-image/CropImage.web.tsx new file mode 100644 index 000000000..1f234c4a6 --- /dev/null +++ b/src/view/com/modals/crop-image/CropImage.web.tsx @@ -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, + }, +}) diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx index d91607b6c..287d94412 100644 --- a/src/view/com/util/UserAvatar.tsx +++ b/src/view/com/util/UserAvatar.tsx @@ -6,8 +6,9 @@ import { openCamera, openCropper, openPicker, - Image as PickedImage, -} from './images/ImageCropPicker' + PickedMedia, +} from './images/image-crop-picker/ImageCropPicker' +import {useStores} from '../../../state' import {colors, gradients} from '../../lib/styles' export function UserAvatar({ @@ -21,8 +22,9 @@ export function UserAvatar({ handle: string displayName: string | undefined avatar?: string | null - onSelectNewAvatar?: (img: PickedImage) => void + onSelectNewAvatar?: (img: PickedMedia) => void }) { + const store = useStores() const initials = getInitials(displayName || handle) const handleEditAvatar = useCallback(() => { @@ -30,37 +32,32 @@ export function UserAvatar({ { text: 'Take a new photo', onPress: () => { - openCamera({ + openCamera(store, { mediaType: 'photo', - cropping: true, width: 1000, height: 1000, cropperCircleOverlay: true, - forceJpg: true, // ios only - compressImageQuality: 1, }).then(onSelectNewAvatar) }, }, { text: 'Select from gallery', onPress: () => { - openPicker({ + openPicker(store, { mediaType: 'photo', - }).then(async item => { - await openCropper({ + }).then(async items => { + await openCropper(store, { mediaType: 'photo', - path: item.path, + path: items[0].path, width: 1000, height: 1000, cropperCircleOverlay: true, - forceJpg: true, // ios only - compressImageQuality: 1, }).then(onSelectNewAvatar) }) }, }, ]) - }, [onSelectNewAvatar]) + }, [store, onSelectNewAvatar]) const renderSvg = (svgSize: number, svgInitials: string) => ( <Svg width={svgSize} height={svgSize} viewBox="0 0 100 100"> diff --git a/src/view/com/util/UserBanner.tsx b/src/view/com/util/UserBanner.tsx index fe606bc55..d5d6e3aaa 100644 --- a/src/view/com/util/UserBanner.tsx +++ b/src/view/com/util/UserBanner.tsx @@ -2,57 +2,56 @@ import React, {useCallback} from 'react' import {StyleSheet, View, TouchableOpacity, Alert, Image} from 'react-native' import Svg, {Rect, Defs, LinearGradient, Stop} from 'react-native-svg' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {Image as PickedImage} from './images/ImageCropPicker' 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({ banner, onSelectNewBanner, }: { banner?: string | null - onSelectNewBanner?: (img: PickedImage) => void + onSelectNewBanner?: (img: PickedMedia) => void }) { + const store = useStores() const handleEditBanner = useCallback(() => { Alert.alert('Select upload method', '', [ { text: 'Take a new photo', onPress: () => { - openCamera({ + openCamera(store, { mediaType: 'photo', - cropping: true, - compressImageMaxWidth: 3000, + // compressImageMaxWidth: 3000, TODO needed? width: 3000, - compressImageMaxHeight: 1000, + // compressImageMaxHeight: 1000, TODO needed? height: 1000, - forceJpg: true, // ios only - compressImageQuality: 1, - includeExif: true, }).then(onSelectNewBanner) }, }, { text: 'Select from gallery', onPress: () => { - openPicker({ + openPicker(store, { mediaType: 'photo', - }).then(async item => { - await openCropper({ + }).then(async items => { + await openCropper(store, { mediaType: 'photo', - path: item.path, - compressImageMaxWidth: 3000, + path: items[0].path, + // compressImageMaxWidth: 3000, TODO needed? width: 3000, - compressImageMaxHeight: 1000, + // compressImageMaxHeight: 1000, TODO needed? height: 1000, - forceJpg: true, // ios only - compressImageQuality: 1, - includeExif: true, }).then(onSelectNewBanner) }) }, }, ]) - }, [onSelectNewBanner]) + }, [store, onSelectNewBanner]) const renderSvg = () => ( <Svg width="100%" height="150" viewBox="50 0 200 100"> diff --git a/src/view/com/util/images/ImageCropPicker.tsx b/src/view/com/util/images/ImageCropPicker.tsx deleted file mode 100644 index 9cd4da9f5..000000000 --- a/src/view/com/util/images/ImageCropPicker.tsx +++ /dev/null @@ -1,6 +0,0 @@ -export { - openPicker, - openCamera, - openCropper, -} from 'react-native-image-crop-picker' -export type {Image} from 'react-native-image-crop-picker' diff --git a/src/view/com/util/images/ImageCropPicker.web.tsx b/src/view/com/util/images/ImageCropPicker.web.tsx deleted file mode 100644 index a385e2e93..000000000 --- a/src/view/com/util/images/ImageCropPicker.web.tsx +++ /dev/null @@ -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') -} diff --git a/src/view/com/util/images/image-crop-picker/ImageCropPicker.tsx b/src/view/com/util/images/image-crop-picker/ImageCropPicker.tsx new file mode 100644 index 000000000..ddc9e87fd --- /dev/null +++ b/src/view/com/util/images/image-crop-picker/ImageCropPicker.tsx @@ -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, + } +} diff --git a/src/view/com/util/images/image-crop-picker/ImageCropPicker.web.tsx b/src/view/com/util/images/image-crop-picker/ImageCropPicker.web.tsx new file mode 100644 index 000000000..a7037f3a4 --- /dev/null +++ b/src/view/com/util/images/image-crop-picker/ImageCropPicker.web.tsx @@ -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() + }) +} diff --git a/src/view/com/util/images/image-crop-picker/types.ts b/src/view/com/util/images/image-crop-picker/types.ts new file mode 100644 index 000000000..3197b4d3e --- /dev/null +++ b/src/view/com/util/images/image-crop-picker/types.ts @@ -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 +} diff --git a/src/view/lib/icons.tsx b/src/view/lib/icons.tsx index 23a8e29dd..f400c3f72 100644 --- a/src/view/lib/icons.tsx +++ b/src/view/lib/icons.tsx @@ -1,6 +1,6 @@ import React from 'react' 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({ style, @@ -458,3 +458,72 @@ export function CommentBottomArrow({ </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> + ) +} |