From 7916b26aadb7e003728d9dc653ab8b8deabf4076 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Fri, 27 Jan 2023 15:51:24 -0600 Subject: [PATCH] Break out the web/native image picking code and make some progress on the web version --- package.json | 3 + public/index.html | 3 +- src/state/models/profile-view.ts | 2 +- src/state/models/shell-ui.ts | 16 +- src/state/models/user-local-photos.ts | 11 +- src/view/com/composer/ComposePost.tsx | 27 +-- .../{ => photos}/PhotoCarouselPicker.tsx | 68 +++++--- .../photos/PhotoCarouselPicker.web.tsx | 158 +++++++++++++++++ src/view/com/modals/EditProfile.tsx | 10 +- src/view/com/modals/Modal.web.tsx | 7 + src/view/com/modals/crop-image/CropImage.tsx | 11 ++ .../com/modals/crop-image/CropImage.web.tsx | 164 ++++++++++++++++++ src/view/com/util/UserAvatar.tsx | 25 ++- src/view/com/util/UserBanner.tsx | 39 ++--- src/view/com/util/images/ImageCropPicker.tsx | 6 - .../com/util/images/ImageCropPicker.web.tsx | 32 ---- .../image-crop-picker/ImageCropPicker.tsx | 92 ++++++++++ .../image-crop-picker/ImageCropPicker.web.tsx | 75 ++++++++ .../util/images/image-crop-picker/types.ts | 31 ++++ src/view/lib/icons.tsx | 71 +++++++- yarn.lock | 25 ++- 21 files changed, 738 insertions(+), 138 deletions(-) rename src/view/com/composer/{ => photos}/PhotoCarouselPicker.tsx (73%) create mode 100644 src/view/com/composer/photos/PhotoCarouselPicker.web.tsx create mode 100644 src/view/com/modals/crop-image/CropImage.tsx create mode 100644 src/view/com/modals/crop-image/CropImage.web.tsx delete mode 100644 src/view/com/util/images/ImageCropPicker.tsx delete mode 100644 src/view/com/util/images/ImageCropPicker.web.tsx create mode 100644 src/view/com/util/images/image-crop-picker/ImageCropPicker.tsx create mode 100644 src/view/com/util/images/image-crop-picker/ImageCropPicker.web.tsx create mode 100644 src/view/com/util/images/image-crop-picker/types.ts diff --git a/package.json b/package.json index 8b135531..6e55b39e 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "@fortawesome/react-native-fontawesome": "^0.3.0", "@gorhom/bottom-sheet": "^4", "@mattermost/react-native-paste-input": "^0.6.0", + "@miblanchard/react-native-slider": "^2.2.0", "@notifee/react-native": "^7.4.0", "@react-native-async-storage/async-storage": "^1.17.6", "@react-native-camera-roll/camera-roll": "^5.2.2", @@ -42,6 +43,7 @@ "mobx": "^6.6.1", "mobx-react-lite": "^3.4.0", "react": "18.2.0", + "react-avatar-editor": "^13.0.0", "react-circular-progressbar": "^2.1.0", "react-dom": "^18.2.0", "react-native": "0.71.0", @@ -84,6 +86,7 @@ "@types/jest": "^26.0.23", "@types/lodash.chunk": "^4.2.7", "@types/lodash.omit": "^4.5.7", + "@types/react-avatar-editor": "^13.0.0", "@types/react-native": "^0.67.3", "@types/react-test-renderer": "^17.0.1", "@typescript-eslint/eslint-plugin": "^5.48.2", diff --git a/public/index.html b/public/index.html index 59487592..575e0421 100644 --- a/public/index.html +++ b/public/index.html @@ -13,8 +13,7 @@ #app-root { display:flex; height:100%; } /* Remove focus state on inputs */ - input:focus, - textarea:focus { + *:focus { outline: 0; } diff --git a/src/state/models/profile-view.ts b/src/state/models/profile-view.ts index 3228c57e..79882a56 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 b84d6ece..09ffd265 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 08b2b390..b14e8a6a 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 2f30a1cf..1144b5e4 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( - () => 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({ /> )} - {isSelectingPhotos && - localPhotos.photos != null && - selectedPhotos.length < 4 && ( - - )} + {isSelectingPhotos && selectedPhotos.length < 4 && ( + + )} 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 ( - {localPhotos.photos.map((item: PhotoIdentifier, index: number) => ( - handleSelectPhoto(item)}> - - - ))} + {localPhotos != null && + localPhotos.photos.map((item: PhotoIdentifier, index: number) => ( + handleSelectPhoto(item)}> + + + ))} ) } 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 00000000..bb280002 --- /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 ( + + + + + + + + + ) +} + +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 1c139e9b..12b72a39 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( profileView.avatar, ) - const [newUserBanner, setNewUserBanner] = useState() - const [newUserAvatar, setNewUserAvatar] = useState() + const [newUserBanner, setNewUserBanner] = useState() + const [newUserAvatar, setNewUserAvatar] = useState() 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 25493312..44ea95f0 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 = } else if (store.shell.activeModal?.name === 'report-account') { element = + } else if (store.shell.activeModal?.name === 'crop-image') { + element = ( + + ) } 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 00000000..9ac3f277 --- /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 00000000..1f234c4a --- /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 = { + [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.Square) + const [scale, setScale] = React.useState(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 ( + + + + + + + setScale(Array.isArray(v) ? v[0] : v) + } + minimumValue={1} + maximumValue={3} + containerStyle={styles.slider} + /> + + + + + + + + + + + + + + Cancel + + + + + + + Done + + + + + + ) +} + +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 d91607b6..287d9441 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) => ( diff --git a/src/view/com/util/UserBanner.tsx b/src/view/com/util/UserBanner.tsx index fe606bc5..d5d6e3aa 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 = () => ( diff --git a/src/view/com/util/images/ImageCropPicker.tsx b/src/view/com/util/images/ImageCropPicker.tsx deleted file mode 100644 index 9cd4da9f..00000000 --- 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 a385e2e9..00000000 --- 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 extends {mediaType: 'photo'} - ? Image - : O extends {mediaType: 'video'} - ? Video - : ImageOrVideo - -export async function openPicker( - _options: O, -): Promise>> { - // TODO - throw new Error('TODO') -} -export async function openCamera( - _options: O, -): Promise>> { - // TODO - throw new Error('TODO') -} -export async function openCropper(_options: Options): Promise { - // 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 00000000..ddc9e87f --- /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 { + 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 { + 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 { + 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 00000000..a7037f3a --- /dev/null +++ b/src/view/com/util/images/image-crop-picker/ImageCropPicker.web.tsx @@ -0,0 +1,75 @@ +/// + +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 { + 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 { + const mediaType = opts.mediaType || 'photo' + throw new Error('TODO') +} + +export async function openCropper( + _store: RootStoreModel, + opts: CropperOpts, +): Promise { + const mediaType = opts.mediaType || 'photo' + throw new Error('TODO') +} + +function selectFile(opts: PickerOpts): Promise { + 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 00000000..3197b4d3 --- /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 23a8e29d..f400c3f7 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({ ) } + +export function SquareIcon({ + style, + size, + strokeWidth = 1.3, +}: { + style?: StyleProp + size?: string | number + strokeWidth?: number +}) { + return ( + + + + ) +} + +export function RectWideIcon({ + style, + size, + strokeWidth = 1.3, +}: { + style?: StyleProp + size?: string | number + strokeWidth?: number +}) { + return ( + + + + ) +} + +export function RectTallIcon({ + style, + size, + strokeWidth = 1.3, +}: { + style?: StyleProp + size?: string | number + strokeWidth?: number +}) { + return ( + + + + ) +} diff --git a/yarn.lock b/yarn.lock index 84d6e3ff..f2f4b957 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1065,7 +1065,7 @@ dependencies: "@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" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.19.6.tgz#9d2a9dbf4e12644d6f46e5e75bfbf02b5d6e9194" integrity sha512-PRH37lz4JU156lYFW1p8OxE5i7d6Sl/zV58ooyr+q1J1lnQPyg5tIiXlIwNVhJaY4W3TmOtdc8jqdXQcB1v5Yw== @@ -2088,6 +2088,11 @@ dependencies: 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": 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" @@ -2900,6 +2905,13 @@ resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.4.tgz#cd667bcfdd025213aafb7ca5915a932590acdcdc" 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": version "0.67.17" resolved "https://registry.yarnpkg.com/@types/react-native/-/react-native-0.67.17.tgz#afebc3fff1d6314840c13b7936e17fa350eb7aae" @@ -2914,7 +2926,7 @@ dependencies: "@types/react" "^17" -"@types/react@^17": +"@types/react@*", "@types/react@^17": version "17.0.52" resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.52.tgz#10d8b907b5c563ac014a541f289ae8eaa9bf2e9b" integrity sha512-vwk8QqVODi0VaZZpDXQCmEmiOuyjEFPY7Ttaw5vjM112LOq37yz1CDJGrRJwA1fYEq4Iitd5rnjd1yWAc/bT+A== @@ -11162,6 +11174,15 @@ react-app-polyfill@^3.0.0: regenerator-runtime "^0.13.9" 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: version "2.1.0" resolved "https://registry.yarnpkg.com/react-circular-progressbar/-/react-circular-progressbar-2.1.0.tgz#99e5ae499c21de82223b498289e96f66adb8fa3a"