Bluesky app fork with some witchin' additions 💫

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

+738 -138
+3
package.json
··· 25 25 "@fortawesome/react-native-fontawesome": "^0.3.0", 26 26 "@gorhom/bottom-sheet": "^4", 27 27 "@mattermost/react-native-paste-input": "^0.6.0", 28 + "@miblanchard/react-native-slider": "^2.2.0", 28 29 "@notifee/react-native": "^7.4.0", 29 30 "@react-native-async-storage/async-storage": "^1.17.6", 30 31 "@react-native-camera-roll/camera-roll": "^5.2.2", ··· 42 43 "mobx": "^6.6.1", 43 44 "mobx-react-lite": "^3.4.0", 44 45 "react": "18.2.0", 46 + "react-avatar-editor": "^13.0.0", 45 47 "react-circular-progressbar": "^2.1.0", 46 48 "react-dom": "^18.2.0", 47 49 "react-native": "0.71.0", ··· 84 86 "@types/jest": "^26.0.23", 85 87 "@types/lodash.chunk": "^4.2.7", 86 88 "@types/lodash.omit": "^4.5.7", 89 + "@types/react-avatar-editor": "^13.0.0", 87 90 "@types/react-native": "^0.67.3", 88 91 "@types/react-test-renderer": "^17.0.1", 89 92 "@typescript-eslint/eslint-plugin": "^5.48.2",
+1 -2
public/index.html
··· 13 13 #app-root { display:flex; height:100%; } 14 14 15 15 /* Remove focus state on inputs */ 16 - input:focus, 17 - textarea:focus { 16 + *:focus { 18 17 outline: 0; 19 18 } 20 19 </style>
+1 -1
src/state/models/profile-view.ts
··· 1 1 import {makeAutoObservable, runInAction} from 'mobx' 2 - import {Image as PickedImage} from '../../view/com/util/images/ImageCropPicker' 2 + import {Image as PickedImage} from '../../view/com/util/images/image-crop-picker/ImageCropPicker' 3 3 import { 4 4 AppBskyActorGetProfile as GetProfile, 5 5 AppBskyActorProfile as Profile,
+15 -1
src/state/models/shell-ui.ts
··· 1 1 import {makeAutoObservable} from 'mobx' 2 2 import {ProfileViewModel} from './profile-view' 3 3 import {isObj, hasProp} from '../lib/type-guards' 4 + import {PickedMedia} from '../../view/com/util/images/image-crop-picker/types' 4 5 5 6 export class ConfirmModal { 6 7 name = 'confirm' ··· 52 53 } 53 54 } 54 55 56 + export class CropImageModal { 57 + name = 'crop-image' 58 + 59 + constructor( 60 + public uri: string, 61 + public onSelect: (img?: PickedMedia) => void, 62 + ) { 63 + makeAutoObservable(this) 64 + } 65 + } 66 + 55 67 interface LightboxModel {} 56 68 57 69 export class ProfileImageLightbox implements LightboxModel { ··· 98 110 | ServerInputModal 99 111 | ReportPostModal 100 112 | ReportAccountModal 113 + | CropImageModal 101 114 | undefined 102 115 isLightboxActive = false 103 116 activeLightbox: ProfileImageLightbox | ImagesLightbox | undefined ··· 140 153 | EditProfileModal 141 154 | ServerInputModal 142 155 | ReportPostModal 143 - | ReportAccountModal, 156 + | ReportAccountModal 157 + | CropImageModal, 144 158 ) { 145 159 this.isModalActive = true 146 160 this.activeModal = modal
+3 -8
src/state/models/user-local-photos.ts
··· 16 16 } 17 17 18 18 async setup() { 19 - await this._getPhotos() 20 - } 21 - 22 - private async _getPhotos() { 23 - CameraRoll.getPhotos({first: 20}).then(r => { 24 - runInAction(() => { 25 - this.photos = r.edges 26 - }) 19 + const r = await CameraRoll.getPhotos({first: 20}) 20 + runInAction(() => { 21 + this.photos = r.edges 27 22 }) 28 23 } 29 24 }
+9 -18
src/view/com/composer/ComposePost.tsx
··· 37 37 } from '../../../lib/strings' 38 38 import {getLinkMeta} from '../../../lib/link-meta' 39 39 import {downloadAndResize} from '../../../lib/images' 40 - import {UserLocalPhotosModel} from '../../../state/models/user-local-photos' 41 - import {PhotoCarouselPicker, cropPhoto} from './PhotoCarouselPicker' 40 + import {PhotoCarouselPicker, cropPhoto} from './photos/PhotoCarouselPicker' 42 41 import {SelectedPhoto} from './SelectedPhoto' 43 42 import {usePalette} from '../../lib/hooks/usePalette' 44 43 ··· 77 76 () => new UserAutocompleteViewModel(store), 78 77 [store], 79 78 ) 80 - const localPhotos = React.useMemo<UserLocalPhotosModel>( 81 - () => new UserLocalPhotosModel(store), 82 - [store], 83 - ) 84 79 85 80 // HACK 86 81 // there's a bug with @mattermost/react-native-paste-input where if the input ··· 95 90 // initial setup 96 91 useEffect(() => { 97 92 autocompleteView.setup() 98 - localPhotos.setup() 99 - }, [autocompleteView, localPhotos]) 93 + }, [autocompleteView]) 100 94 101 95 // external link metadata-fetch flow 102 96 useEffect(() => { ··· 220 214 } 221 215 const imgUri = uris.find(uri => /\.(jpe?g|png)$/.test(uri)) 222 216 if (imgUri) { 223 - const finalImgPath = await cropPhoto(imgUri) 217 + const finalImgPath = await cropPhoto(store, imgUri) 224 218 onSelectPhotos([...selectedPhotos, finalImgPath]) 225 219 } 226 220 } ··· 412 406 /> 413 407 )} 414 408 </ScrollView> 415 - {isSelectingPhotos && 416 - localPhotos.photos != null && 417 - selectedPhotos.length < 4 && ( 418 - <PhotoCarouselPicker 419 - selectedPhotos={selectedPhotos} 420 - onSelectPhotos={onSelectPhotos} 421 - localPhotos={localPhotos} 422 - /> 423 - )} 409 + {isSelectingPhotos && selectedPhotos.length < 4 && ( 410 + <PhotoCarouselPicker 411 + selectedPhotos={selectedPhotos} 412 + onSelectPhotos={onSelectPhotos} 413 + /> 414 + )} 424 415 <View style={[pal.border, styles.bottomBar]}> 425 416 <TouchableOpacity 426 417 testID="composerSelectPhotosButton"
+40 -28
src/view/com/composer/PhotoCarouselPicker.tsx src/view/com/composer/photos/PhotoCarouselPicker.tsx
··· 8 8 openPicker, 9 9 openCamera, 10 10 openCropper, 11 - } from '../util/images/ImageCropPicker' 11 + } from '../../util/images/image-crop-picker/ImageCropPicker' 12 12 import { 13 13 UserLocalPhotosModel, 14 14 PhotoIdentifier, 15 - } from '../../../state/models/user-local-photos' 16 - import {compressIfNeeded, scaleDownDimensions} from '../../../lib/images' 17 - import {usePalette} from '../../lib/hooks/usePalette' 18 - import {useStores} from '../../../state' 15 + } from '../../../../state/models/user-local-photos' 16 + import {compressIfNeeded, scaleDownDimensions} from '../../../../lib/images' 17 + import {usePalette} from '../../../lib/hooks/usePalette' 18 + import {useStores, RootStoreModel} from '../../../../state' 19 19 20 20 const MAX_WIDTH = 1000 21 21 const MAX_HEIGHT = 1000 ··· 25 25 width: 1000, 26 26 height: 1000, 27 27 freeStyleCropEnabled: true, 28 - forceJpg: true, // ios only 29 - compressImageQuality: 1.0, 30 28 } 31 29 32 30 export async function cropPhoto( 31 + store: RootStoreModel, 33 32 path: string, 34 33 imgWidth = MAX_WIDTH, 35 34 imgHeight = MAX_HEIGHT, ··· 40 39 {width: imgWidth, height: imgHeight}, 41 40 {width: MAX_WIDTH, height: MAX_HEIGHT}, 42 41 ) 43 - const cropperRes = await openCropper({ 42 + const cropperRes = await openCropper(store, { 44 43 mediaType: 'photo', 45 44 path, 46 - ...IMAGE_PARAMS, 45 + freeStyleCropEnabled: true, 47 46 width, 48 47 height, 49 48 }) ··· 54 53 export const PhotoCarouselPicker = ({ 55 54 selectedPhotos, 56 55 onSelectPhotos, 57 - localPhotos, 58 56 }: { 59 57 selectedPhotos: string[] 60 58 onSelectPhotos: (v: string[]) => void 61 - localPhotos: UserLocalPhotosModel 62 59 }) => { 63 60 const pal = usePalette('default') 64 61 const store = useStores() 62 + const [localPhotos, setLocalPhotos] = React.useState< 63 + UserLocalPhotosModel | undefined 64 + >(undefined) 65 + 66 + // initial setup 67 + React.useEffect(() => { 68 + const photos = new UserLocalPhotosModel(store) 69 + photos.setup().then(() => { 70 + if (photos.photos) { 71 + setLocalPhotos(photos) 72 + } 73 + }) 74 + }, [store]) 75 + 65 76 const handleOpenCamera = useCallback(async () => { 66 77 try { 67 - const cameraRes = await openCamera({ 78 + const cameraRes = await openCamera(store, { 68 79 mediaType: 'photo', 69 - cropping: true, 70 80 ...IMAGE_PARAMS, 71 81 }) 72 82 const img = await compressIfNeeded(cameraRes, MAX_SIZE) ··· 75 85 // ignore 76 86 store.log.warn('Error using camera', err) 77 87 } 78 - }, [store.log, selectedPhotos, onSelectPhotos]) 88 + }, [store, selectedPhotos, onSelectPhotos]) 79 89 80 90 const handleSelectPhoto = useCallback( 81 91 async (item: PhotoIdentifier) => { 82 92 try { 83 93 const imgPath = await cropPhoto( 94 + store, 84 95 item.node.image.uri, 85 96 item.node.image.width, 86 97 item.node.image.height, ··· 91 102 store.log.warn('Error selecting photo', err) 92 103 } 93 104 }, 94 - [store.log, selectedPhotos, onSelectPhotos], 105 + [store, selectedPhotos, onSelectPhotos], 95 106 ) 96 107 97 108 const handleOpenGallery = useCallback(() => { 98 - openPicker({ 109 + openPicker(store, { 99 110 multiple: true, 100 111 maxFiles: 4 - selectedPhotos.length, 101 112 mediaType: 'photo', ··· 109 120 {width: image.width, height: image.height}, 110 121 {width: MAX_WIDTH, height: MAX_HEIGHT}, 111 122 ) 112 - const cropperRes = await openCropper({ 123 + const cropperRes = await openCropper(store, { 113 124 mediaType: 'photo', 114 125 path: image.path, 115 - ...IMAGE_PARAMS, 126 + freeStyleCropEnabled: true, 116 127 width, 117 128 height, 118 129 }) ··· 121 132 } 122 133 onSelectPhotos([...selectedPhotos, ...result]) 123 134 }) 124 - }, [selectedPhotos, onSelectPhotos]) 135 + }, [store, selectedPhotos, onSelectPhotos]) 125 136 126 137 return ( 127 138 <ScrollView ··· 150 161 size={24} 151 162 /> 152 163 </TouchableOpacity> 153 - {localPhotos.photos.map((item: PhotoIdentifier, index: number) => ( 154 - <TouchableOpacity 155 - testID="openSelectPhotoButton" 156 - key={`local-image-${index}`} 157 - style={[pal.border, styles.photoButton]} 158 - onPress={() => handleSelectPhoto(item)}> 159 - <Image style={styles.photo} source={{uri: item.node.image.uri}} /> 160 - </TouchableOpacity> 161 - ))} 164 + {localPhotos != null && 165 + localPhotos.photos.map((item: PhotoIdentifier, index: number) => ( 166 + <TouchableOpacity 167 + testID="openSelectPhotoButton" 168 + key={`local-image-${index}`} 169 + style={[pal.border, styles.photoButton]} 170 + onPress={() => handleSelectPhoto(item)}> 171 + <Image style={styles.photo} source={{uri: item.node.image.uri}} /> 172 + </TouchableOpacity> 173 + ))} 162 174 </ScrollView> 163 175 ) 164 176 }
+158
src/view/com/composer/photos/PhotoCarouselPicker.web.tsx
··· 1 + import React, {useCallback} from 'react' 2 + import {StyleSheet, TouchableOpacity, ScrollView} from 'react-native' 3 + import { 4 + FontAwesomeIcon, 5 + FontAwesomeIconStyle, 6 + } from '@fortawesome/react-native-fontawesome' 7 + import { 8 + openPicker, 9 + openCamera, 10 + openCropper, 11 + } from '../../util/images/image-crop-picker/ImageCropPicker' 12 + import {compressIfNeeded, scaleDownDimensions} from '../../../../lib/images' 13 + import {usePalette} from '../../../lib/hooks/usePalette' 14 + import {useStores, RootStoreModel} from '../../../../state' 15 + 16 + const MAX_WIDTH = 1000 17 + const MAX_HEIGHT = 1000 18 + const MAX_SIZE = 300000 19 + 20 + const IMAGE_PARAMS = { 21 + width: 1000, 22 + height: 1000, 23 + freeStyleCropEnabled: true, 24 + } 25 + 26 + export async function cropPhoto( 27 + store: RootStoreModel, 28 + path: string, 29 + imgWidth = MAX_WIDTH, 30 + imgHeight = MAX_HEIGHT, 31 + ) { 32 + // choose target dimensions based on the original 33 + // this causes the photo cropper to start with the full image "selected" 34 + const {width, height} = scaleDownDimensions( 35 + {width: imgWidth, height: imgHeight}, 36 + {width: MAX_WIDTH, height: MAX_HEIGHT}, 37 + ) 38 + const cropperRes = await openCropper(store, { 39 + mediaType: 'photo', 40 + path, 41 + freeStyleCropEnabled: true, 42 + width, 43 + height, 44 + }) 45 + const img = await compressIfNeeded(cropperRes, MAX_SIZE) 46 + return img.path 47 + } 48 + 49 + export const PhotoCarouselPicker = ({ 50 + selectedPhotos, 51 + onSelectPhotos, 52 + }: { 53 + selectedPhotos: string[] 54 + onSelectPhotos: (v: string[]) => void 55 + }) => { 56 + const pal = usePalette('default') 57 + const store = useStores() 58 + 59 + const handleOpenCamera = useCallback(async () => { 60 + try { 61 + const cameraRes = await openCamera(store, { 62 + mediaType: 'photo', 63 + ...IMAGE_PARAMS, 64 + }) 65 + const img = await compressIfNeeded(cameraRes, MAX_SIZE) 66 + onSelectPhotos([...selectedPhotos, img.path]) 67 + } catch (err: any) { 68 + // ignore 69 + store.log.warn('Error using camera', err) 70 + } 71 + }, [store, selectedPhotos, onSelectPhotos]) 72 + 73 + const handleOpenGallery = useCallback(() => { 74 + openPicker(store, { 75 + multiple: true, 76 + maxFiles: 4 - selectedPhotos.length, 77 + mediaType: 'photo', 78 + }).then(async items => { 79 + const result = [] 80 + 81 + for (const image of items) { 82 + // choose target dimensions based on the original 83 + // this causes the photo cropper to start with the full image "selected" 84 + const {width, height} = scaleDownDimensions( 85 + {width: image.width, height: image.height}, 86 + {width: MAX_WIDTH, height: MAX_HEIGHT}, 87 + ) 88 + const cropperRes = await openCropper(store, { 89 + mediaType: 'photo', 90 + path: image.path, 91 + freeStyleCropEnabled: true, 92 + width, 93 + height, 94 + }) 95 + const finalImg = await compressIfNeeded(cropperRes, MAX_SIZE) 96 + result.push(finalImg.path) 97 + } 98 + onSelectPhotos([...selectedPhotos, ...result]) 99 + }) 100 + }, [store, selectedPhotos, onSelectPhotos]) 101 + 102 + return ( 103 + <ScrollView 104 + testID="photoCarouselPickerView" 105 + horizontal 106 + style={[pal.view, styles.photosContainer]} 107 + keyboardShouldPersistTaps="always" 108 + showsHorizontalScrollIndicator={false}> 109 + <TouchableOpacity 110 + testID="openCameraButton" 111 + style={[styles.galleryButton, pal.border, styles.photo]} 112 + onPress={handleOpenCamera}> 113 + <FontAwesomeIcon 114 + icon="camera" 115 + size={24} 116 + style={pal.link as FontAwesomeIconStyle} 117 + /> 118 + </TouchableOpacity> 119 + <TouchableOpacity 120 + testID="openGalleryButton" 121 + style={[styles.galleryButton, pal.border, styles.photo]} 122 + onPress={handleOpenGallery}> 123 + <FontAwesomeIcon 124 + icon="image" 125 + style={pal.link as FontAwesomeIconStyle} 126 + size={24} 127 + /> 128 + </TouchableOpacity> 129 + </ScrollView> 130 + ) 131 + } 132 + 133 + const styles = StyleSheet.create({ 134 + photosContainer: { 135 + width: '100%', 136 + maxHeight: 96, 137 + padding: 8, 138 + overflow: 'hidden', 139 + }, 140 + galleryButton: { 141 + borderWidth: 1, 142 + alignItems: 'center', 143 + justifyContent: 'center', 144 + }, 145 + photoButton: { 146 + width: 75, 147 + height: 75, 148 + marginRight: 8, 149 + borderWidth: 1, 150 + borderRadius: 16, 151 + }, 152 + photo: { 153 + width: 75, 154 + height: 75, 155 + marginRight: 8, 156 + borderRadius: 16, 157 + }, 158 + })
+5 -5
src/view/com/modals/EditProfile.tsx
··· 8 8 } from 'react-native' 9 9 import LinearGradient from 'react-native-linear-gradient' 10 10 import {ScrollView, TextInput} from './util' 11 - import {Image as PickedImage} from '../util/images/ImageCropPicker' 11 + import {PickedMedia} from '../util/images/image-crop-picker/ImageCropPicker' 12 12 import {Text} from '../util/text/Text' 13 13 import {ErrorMessage} from '../util/error/ErrorMessage' 14 14 import {useStores} from '../../../state' ··· 48 48 const [userAvatar, setUserAvatar] = useState<string | undefined>( 49 49 profileView.avatar, 50 50 ) 51 - const [newUserBanner, setNewUserBanner] = useState<PickedImage | undefined>() 52 - const [newUserAvatar, setNewUserAvatar] = useState<PickedImage | undefined>() 51 + const [newUserBanner, setNewUserBanner] = useState<PickedMedia | undefined>() 52 + const [newUserAvatar, setNewUserAvatar] = useState<PickedMedia | undefined>() 53 53 const onPressCancel = () => { 54 54 store.shell.closeModal() 55 55 } 56 - const onSelectNewAvatar = async (img: PickedImage) => { 56 + const onSelectNewAvatar = async (img: PickedMedia) => { 57 57 try { 58 58 const finalImg = await compressIfNeeded(img, 300000) 59 59 setNewUserAvatar(finalImg) ··· 62 62 setError(e.message || e.toString()) 63 63 } 64 64 } 65 - const onSelectNewBanner = async (img: PickedImage) => { 65 + const onSelectNewBanner = async (img: PickedMedia) => { 66 66 try { 67 67 const finalImg = await compressIfNeeded(img, 500000) 68 68 setNewUserBanner(finalImg)
+7
src/view/com/modals/Modal.web.tsx
··· 11 11 import * as ServerInputModal from './ServerInput' 12 12 import * as ReportPostModal from './ReportPost' 13 13 import * as ReportAccountModal from './ReportAccount' 14 + import * as CropImageModal from './crop-image/CropImage.web' 14 15 15 16 export const Modal = observer(function Modal() { 16 17 const store = useStores() ··· 50 51 element = <ReportPostModal.Component /> 51 52 } else if (store.shell.activeModal?.name === 'report-account') { 52 53 element = <ReportAccountModal.Component /> 54 + } else if (store.shell.activeModal?.name === 'crop-image') { 55 + element = ( 56 + <CropImageModal.Component 57 + {...(store.shell.activeModal as models.CropImageModal)} 58 + /> 59 + ) 53 60 } else { 54 61 return null 55 62 }
+11
src/view/com/modals/crop-image/CropImage.tsx
··· 1 + /** 2 + * NOTE 3 + * This modal is used only in the web build 4 + * Native uses a third-party library 5 + */ 6 + 7 + export const snapPoints = ['0%'] 8 + 9 + export function Component() { 10 + return null 11 + }
+164
src/view/com/modals/crop-image/CropImage.web.tsx
··· 1 + import React from 'react' 2 + import {StyleSheet, TouchableOpacity, View} from 'react-native' 3 + import ImageEditor from 'react-avatar-editor' 4 + import {Slider} from '@miblanchard/react-native-slider' 5 + import LinearGradient from 'react-native-linear-gradient' 6 + import {Text} from '../../util/text/Text' 7 + import {PickedMedia} from '../../util/images/image-crop-picker/types' 8 + import {s, gradients} from '../../../lib/styles' 9 + import {useStores} from '../../../../state' 10 + import {usePalette} from '../../../lib/hooks/usePalette' 11 + import {SquareIcon, RectWideIcon, RectTallIcon} from '../../../lib/icons' 12 + 13 + enum AspectRatio { 14 + Square = 'square', 15 + Wide = 'wide', 16 + Tall = 'tall', 17 + } 18 + interface Dim { 19 + width: number 20 + height: number 21 + } 22 + const DIMS: Record<string, Dim> = { 23 + [AspectRatio.Square]: {width: 1000, height: 1000}, 24 + [AspectRatio.Wide]: {width: 1000, height: 750}, 25 + [AspectRatio.Tall]: {width: 750, height: 1000}, 26 + } 27 + 28 + export const snapPoints = ['0%'] 29 + 30 + export function Component({ 31 + uri, 32 + onSelect, 33 + }: { 34 + uri: string 35 + onSelect: (img?: PickedMedia) => void 36 + }) { 37 + const store = useStores() 38 + const pal = usePalette('default') 39 + const [as, setAs] = React.useState<AspectRatio>(AspectRatio.Square) 40 + const [scale, setScale] = React.useState<number>(1) 41 + 42 + const doSetAs = (v: AspectRatio) => () => setAs(v) 43 + 44 + const onPressCancel = () => { 45 + onSelect(undefined) 46 + store.shell.closeModal() 47 + } 48 + const onPressDone = () => { 49 + console.log('TODO') 50 + onSelect(undefined) // TODO 51 + store.shell.closeModal() 52 + } 53 + 54 + let cropperStyle 55 + if (as === AspectRatio.Square) { 56 + cropperStyle = styles.cropperSquare 57 + } else if (as === AspectRatio.Wide) { 58 + cropperStyle = styles.cropperWide 59 + } else if (as === AspectRatio.Tall) { 60 + cropperStyle = styles.cropperTall 61 + } 62 + return ( 63 + <View> 64 + <View style={[styles.cropper, cropperStyle]}> 65 + <ImageEditor 66 + style={styles.imageEditor} 67 + image={uri} 68 + width={DIMS[as].width} 69 + height={DIMS[as].height} 70 + scale={scale} 71 + /> 72 + </View> 73 + <View style={styles.ctrls}> 74 + <Slider 75 + value={scale} 76 + onValueChange={(v: number | number[]) => 77 + setScale(Array.isArray(v) ? v[0] : v) 78 + } 79 + minimumValue={1} 80 + maximumValue={3} 81 + containerStyle={styles.slider} 82 + /> 83 + <TouchableOpacity onPress={doSetAs(AspectRatio.Wide)}> 84 + <RectWideIcon 85 + size={24} 86 + style={as === AspectRatio.Wide ? s.blue3 : undefined} 87 + /> 88 + </TouchableOpacity> 89 + <TouchableOpacity onPress={doSetAs(AspectRatio.Tall)}> 90 + <RectTallIcon 91 + size={24} 92 + style={as === AspectRatio.Tall ? s.blue3 : undefined} 93 + /> 94 + </TouchableOpacity> 95 + <TouchableOpacity onPress={doSetAs(AspectRatio.Square)}> 96 + <SquareIcon 97 + size={24} 98 + style={as === AspectRatio.Square ? s.blue3 : undefined} 99 + /> 100 + </TouchableOpacity> 101 + </View> 102 + <View style={styles.btns}> 103 + <TouchableOpacity onPress={onPressCancel}> 104 + <Text type="xl" style={pal.link}> 105 + Cancel 106 + </Text> 107 + </TouchableOpacity> 108 + <View style={s.flex1} /> 109 + <TouchableOpacity onPress={onPressDone}> 110 + <LinearGradient 111 + colors={[gradients.blueLight.start, gradients.blueLight.end]} 112 + start={{x: 0, y: 0}} 113 + end={{x: 1, y: 1}} 114 + style={[styles.btn]}> 115 + <Text type="xl-medium" style={s.white}> 116 + Done 117 + </Text> 118 + </LinearGradient> 119 + </TouchableOpacity> 120 + </View> 121 + </View> 122 + ) 123 + } 124 + 125 + const styles = StyleSheet.create({ 126 + cropper: { 127 + marginLeft: 'auto', 128 + marginRight: 'auto', 129 + }, 130 + cropperSquare: { 131 + width: 400, 132 + height: 400, 133 + }, 134 + cropperWide: { 135 + width: 400, 136 + height: 300, 137 + }, 138 + cropperTall: { 139 + width: 300, 140 + height: 400, 141 + }, 142 + imageEditor: { 143 + maxWidth: '100%', 144 + }, 145 + ctrls: { 146 + flexDirection: 'row', 147 + alignItems: 'center', 148 + marginTop: 10, 149 + }, 150 + slider: { 151 + flex: 1, 152 + marginRight: 10, 153 + }, 154 + btns: { 155 + flexDirection: 'row', 156 + alignItems: 'center', 157 + marginTop: 10, 158 + }, 159 + btn: { 160 + borderRadius: 4, 161 + paddingVertical: 8, 162 + paddingHorizontal: 24, 163 + }, 164 + })
+11 -14
src/view/com/util/UserAvatar.tsx
··· 6 6 openCamera, 7 7 openCropper, 8 8 openPicker, 9 - Image as PickedImage, 10 - } from './images/ImageCropPicker' 9 + PickedMedia, 10 + } from './images/image-crop-picker/ImageCropPicker' 11 + import {useStores} from '../../../state' 11 12 import {colors, gradients} from '../../lib/styles' 12 13 13 14 export function UserAvatar({ ··· 21 22 handle: string 22 23 displayName: string | undefined 23 24 avatar?: string | null 24 - onSelectNewAvatar?: (img: PickedImage) => void 25 + onSelectNewAvatar?: (img: PickedMedia) => void 25 26 }) { 27 + const store = useStores() 26 28 const initials = getInitials(displayName || handle) 27 29 28 30 const handleEditAvatar = useCallback(() => { ··· 30 32 { 31 33 text: 'Take a new photo', 32 34 onPress: () => { 33 - openCamera({ 35 + openCamera(store, { 34 36 mediaType: 'photo', 35 - cropping: true, 36 37 width: 1000, 37 38 height: 1000, 38 39 cropperCircleOverlay: true, 39 - forceJpg: true, // ios only 40 - compressImageQuality: 1, 41 40 }).then(onSelectNewAvatar) 42 41 }, 43 42 }, 44 43 { 45 44 text: 'Select from gallery', 46 45 onPress: () => { 47 - openPicker({ 46 + openPicker(store, { 48 47 mediaType: 'photo', 49 - }).then(async item => { 50 - await openCropper({ 48 + }).then(async items => { 49 + await openCropper(store, { 51 50 mediaType: 'photo', 52 - path: item.path, 51 + path: items[0].path, 53 52 width: 1000, 54 53 height: 1000, 55 54 cropperCircleOverlay: true, 56 - forceJpg: true, // ios only 57 - compressImageQuality: 1, 58 55 }).then(onSelectNewAvatar) 59 56 }) 60 57 }, 61 58 }, 62 59 ]) 63 - }, [onSelectNewAvatar]) 60 + }, [store, onSelectNewAvatar]) 64 61 65 62 const renderSvg = (svgSize: number, svgInitials: string) => ( 66 63 <Svg width={svgSize} height={svgSize} viewBox="0 0 100 100">
+19 -20
src/view/com/util/UserBanner.tsx
··· 2 2 import {StyleSheet, View, TouchableOpacity, Alert, Image} from 'react-native' 3 3 import Svg, {Rect, Defs, LinearGradient, Stop} from 'react-native-svg' 4 4 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 5 - import {Image as PickedImage} from './images/ImageCropPicker' 6 5 import {colors, gradients} from '../../lib/styles' 7 - import {openCamera, openCropper, openPicker} from './images/ImageCropPicker' 6 + import { 7 + openCamera, 8 + openCropper, 9 + openPicker, 10 + PickedMedia, 11 + } from './images/image-crop-picker/ImageCropPicker' 12 + import {useStores} from '../../../state' 8 13 9 14 export function UserBanner({ 10 15 banner, 11 16 onSelectNewBanner, 12 17 }: { 13 18 banner?: string | null 14 - onSelectNewBanner?: (img: PickedImage) => void 19 + onSelectNewBanner?: (img: PickedMedia) => void 15 20 }) { 21 + const store = useStores() 16 22 const handleEditBanner = useCallback(() => { 17 23 Alert.alert('Select upload method', '', [ 18 24 { 19 25 text: 'Take a new photo', 20 26 onPress: () => { 21 - openCamera({ 27 + openCamera(store, { 22 28 mediaType: 'photo', 23 - cropping: true, 24 - compressImageMaxWidth: 3000, 29 + // compressImageMaxWidth: 3000, TODO needed? 25 30 width: 3000, 26 - compressImageMaxHeight: 1000, 31 + // compressImageMaxHeight: 1000, TODO needed? 27 32 height: 1000, 28 - forceJpg: true, // ios only 29 - compressImageQuality: 1, 30 - includeExif: true, 31 33 }).then(onSelectNewBanner) 32 34 }, 33 35 }, 34 36 { 35 37 text: 'Select from gallery', 36 38 onPress: () => { 37 - openPicker({ 39 + openPicker(store, { 38 40 mediaType: 'photo', 39 - }).then(async item => { 40 - await openCropper({ 41 + }).then(async items => { 42 + await openCropper(store, { 41 43 mediaType: 'photo', 42 - path: item.path, 43 - compressImageMaxWidth: 3000, 44 + path: items[0].path, 45 + // compressImageMaxWidth: 3000, TODO needed? 44 46 width: 3000, 45 - compressImageMaxHeight: 1000, 47 + // compressImageMaxHeight: 1000, TODO needed? 46 48 height: 1000, 47 - forceJpg: true, // ios only 48 - compressImageQuality: 1, 49 - includeExif: true, 50 49 }).then(onSelectNewBanner) 51 50 }) 52 51 }, 53 52 }, 54 53 ]) 55 - }, [onSelectNewBanner]) 54 + }, [store, onSelectNewBanner]) 56 55 57 56 const renderSvg = () => ( 58 57 <Svg width="100%" height="150" viewBox="50 0 200 100">
-6
src/view/com/util/images/ImageCropPicker.tsx
··· 1 - export { 2 - openPicker, 3 - openCamera, 4 - openCropper, 5 - } from 'react-native-image-crop-picker' 6 - export type {Image} from 'react-native-image-crop-picker'
-32
src/view/com/util/images/ImageCropPicker.web.tsx
··· 1 - import type { 2 - Image, 3 - Video, 4 - ImageOrVideo, 5 - Options, 6 - PossibleArray, 7 - } from 'react-native-image-crop-picker' 8 - 9 - export type {Image} from 'react-native-image-crop-picker' 10 - 11 - type MediaType<O> = O extends {mediaType: 'photo'} 12 - ? Image 13 - : O extends {mediaType: 'video'} 14 - ? Video 15 - : ImageOrVideo 16 - 17 - export async function openPicker<O extends Options>( 18 - _options: O, 19 - ): Promise<PossibleArray<O, MediaType<O>>> { 20 - // TODO 21 - throw new Error('TODO') 22 - } 23 - export async function openCamera<O extends Options>( 24 - _options: O, 25 - ): Promise<PossibleArray<O, MediaType<O>>> { 26 - // TODO 27 - throw new Error('TODO') 28 - } 29 - export async function openCropper(_options: Options): Promise<Image> { 30 - // TODO 31 - throw new Error('TODO') 32 - }
+92
src/view/com/util/images/image-crop-picker/ImageCropPicker.tsx
··· 1 + import { 2 + openPicker as openPickerFn, 3 + openCamera as openCameraFn, 4 + openCropper as openCropperFn, 5 + ImageOrVideo, 6 + } from 'react-native-image-crop-picker' 7 + import {RootStoreModel} from '../../../../../state' 8 + import {PickerOpts, CameraOpts, CropperOpts, PickedMedia} from './types' 9 + export type {PickedMedia} from './types' 10 + 11 + /** 12 + * NOTE 13 + * These methods all include the RootStoreModel as the first param 14 + * because the web versions require it. The signatures have to remain 15 + * equivalent between the different forms, but the store param is not 16 + * used here. 17 + * -prf 18 + */ 19 + 20 + export async function openPicker( 21 + _store: RootStoreModel, 22 + opts: PickerOpts, 23 + ): Promise<PickedMedia[]> { 24 + const mediaType = opts.mediaType || 'photo' 25 + const items = await openPickerFn({ 26 + mediaType, 27 + multiple: opts.multiple, 28 + maxFiles: opts.maxFiles, 29 + }) 30 + const toMedia = (item: ImageOrVideo) => ({ 31 + mediaType, 32 + path: item.path, 33 + mime: item.mime, 34 + size: item.size, 35 + width: item.width, 36 + height: item.height, 37 + }) 38 + if (Array.isArray(items)) { 39 + return items.map(toMedia) 40 + } 41 + return [toMedia(items)] 42 + } 43 + 44 + export async function openCamera( 45 + _store: RootStoreModel, 46 + opts: CameraOpts, 47 + ): Promise<PickedMedia> { 48 + const mediaType = opts.mediaType || 'photo' 49 + const item = await openCameraFn({ 50 + mediaType, 51 + width: opts.width, 52 + height: opts.height, 53 + freeStyleCropEnabled: opts.freeStyleCropEnabled, 54 + cropperCircleOverlay: opts.cropperCircleOverlay, 55 + cropping: true, 56 + forceJpg: true, // ios only 57 + compressImageQuality: 1.0, 58 + }) 59 + return { 60 + mediaType, 61 + path: item.path, 62 + mime: item.mime, 63 + size: item.size, 64 + width: item.width, 65 + height: item.height, 66 + } 67 + } 68 + 69 + export async function openCropper( 70 + _store: RootStoreModel, 71 + opts: CropperOpts, 72 + ): Promise<PickedMedia> { 73 + const mediaType = opts.mediaType || 'photo' 74 + const item = await openCropperFn({ 75 + path: opts.path, 76 + mediaType: opts.mediaType || 'photo', 77 + width: opts.width, 78 + height: opts.height, 79 + freeStyleCropEnabled: opts.freeStyleCropEnabled, 80 + cropperCircleOverlay: opts.cropperCircleOverlay, 81 + forceJpg: true, // ios only 82 + compressImageQuality: 1.0, 83 + }) 84 + return { 85 + mediaType, 86 + path: item.path, 87 + mime: item.mime, 88 + size: item.size, 89 + width: item.width, 90 + height: item.height, 91 + } 92 + }
+75
src/view/com/util/images/image-crop-picker/ImageCropPicker.web.tsx
··· 1 + /// <reference lib="dom" /> 2 + 3 + import {CropImageModal} from '../../../../../state/models/shell-ui' 4 + import {PickerOpts, CameraOpts, CropperOpts, PickedMedia} from './types' 5 + export type {PickedMedia} from './types' 6 + import {RootStoreModel} from '../../../../../state' 7 + 8 + interface PickedFile { 9 + uri: string 10 + path: string 11 + size: number 12 + } 13 + 14 + export async function openPicker( 15 + store: RootStoreModel, 16 + opts: PickerOpts, 17 + ): Promise<PickedMedia[] | PickedMedia> { 18 + const res = await selectFile(opts) 19 + return new Promise((resolve, reject) => { 20 + store.shell.openModal( 21 + new CropImageModal(res.uri, (img?: PickedMedia) => { 22 + if (img) { 23 + resolve(img) 24 + } else { 25 + reject(new Error('Canceled')) 26 + } 27 + }), 28 + ) 29 + }) 30 + } 31 + 32 + export async function openCamera( 33 + _store: RootStoreModel, 34 + opts: CameraOpts, 35 + ): Promise<PickedMedia> { 36 + const mediaType = opts.mediaType || 'photo' 37 + throw new Error('TODO') 38 + } 39 + 40 + export async function openCropper( 41 + _store: RootStoreModel, 42 + opts: CropperOpts, 43 + ): Promise<PickedMedia> { 44 + const mediaType = opts.mediaType || 'photo' 45 + throw new Error('TODO') 46 + } 47 + 48 + function selectFile(opts: PickerOpts): Promise<PickedFile> { 49 + return new Promise((resolve, reject) => { 50 + var input = document.createElement('input') 51 + input.type = 'file' 52 + input.accept = opts.mediaType === 'photo' ? 'image/*' : '*/*' 53 + input.onchange = e => { 54 + const target = e.target as HTMLInputElement 55 + const file = target?.files?.[0] 56 + if (!file) { 57 + return reject(new Error('Canceled')) 58 + } 59 + 60 + var reader = new FileReader() 61 + reader.readAsDataURL(file) 62 + reader.onload = readerEvent => { 63 + if (!readerEvent.target) { 64 + return reject(new Error('Canceled')) 65 + } 66 + resolve({ 67 + uri: readerEvent.target.result as string, 68 + path: file.name, 69 + size: file.size, 70 + }) 71 + } 72 + } 73 + input.click() 74 + }) 75 + }
+31
src/view/com/util/images/image-crop-picker/types.ts
··· 1 + export interface PickerOpts { 2 + mediaType?: 'photo' 3 + multiple?: boolean 4 + maxFiles?: number 5 + } 6 + 7 + export interface CameraOpts { 8 + mediaType?: 'photo' 9 + width: number 10 + height: number 11 + freeStyleCropEnabled?: boolean 12 + cropperCircleOverlay?: boolean 13 + } 14 + 15 + export interface CropperOpts { 16 + path: string 17 + mediaType?: 'photo' 18 + width: number 19 + height: number 20 + freeStyleCropEnabled?: boolean 21 + cropperCircleOverlay?: boolean 22 + } 23 + 24 + export interface PickedMedia { 25 + mediaType: 'photo' 26 + path: string 27 + mime: string 28 + size: number 29 + width: number 30 + height: number 31 + }
+70 -1
src/view/lib/icons.tsx
··· 1 1 import React from 'react' 2 2 import {StyleProp, TextStyle, ViewStyle} from 'react-native' 3 - import Svg, {Path} from 'react-native-svg' 3 + import Svg, {Path, Rect} from 'react-native-svg' 4 4 5 5 export function GridIcon({ 6 6 style, ··· 458 458 </Svg> 459 459 ) 460 460 } 461 + 462 + export function SquareIcon({ 463 + style, 464 + size, 465 + strokeWidth = 1.3, 466 + }: { 467 + style?: StyleProp<TextStyle> 468 + size?: string | number 469 + strokeWidth?: number 470 + }) { 471 + return ( 472 + <Svg 473 + fill="none" 474 + viewBox="0 0 24 24" 475 + strokeWidth={strokeWidth || 1} 476 + stroke="currentColor" 477 + width={size || 24} 478 + height={size || 24} 479 + style={style}> 480 + <Rect x="6" y="6" width="12" height="12" strokeLinejoin="round" /> 481 + </Svg> 482 + ) 483 + } 484 + 485 + export function RectWideIcon({ 486 + style, 487 + size, 488 + strokeWidth = 1.3, 489 + }: { 490 + style?: StyleProp<TextStyle> 491 + size?: string | number 492 + strokeWidth?: number 493 + }) { 494 + return ( 495 + <Svg 496 + fill="none" 497 + viewBox="0 0 24 24" 498 + strokeWidth={strokeWidth || 1} 499 + stroke="currentColor" 500 + width={size || 24} 501 + height={size || 24} 502 + style={style}> 503 + <Rect x="4" y="6" width="16" height="12" strokeLinejoin="round" /> 504 + </Svg> 505 + ) 506 + } 507 + 508 + export function RectTallIcon({ 509 + style, 510 + size, 511 + strokeWidth = 1.3, 512 + }: { 513 + style?: StyleProp<TextStyle> 514 + size?: string | number 515 + strokeWidth?: number 516 + }) { 517 + return ( 518 + <Svg 519 + fill="none" 520 + viewBox="0 0 24 24" 521 + strokeWidth={strokeWidth || 1} 522 + stroke="currentColor" 523 + width={size || 24} 524 + height={size || 24} 525 + style={style}> 526 + <Rect x="6" y="4" width="12" height="16" strokeLinejoin="round" /> 527 + </Svg> 528 + ) 529 + }
+23 -2
yarn.lock
··· 1065 1065 dependencies: 1066 1066 "@babel/helper-plugin-utils" "^7.18.6" 1067 1067 1068 - "@babel/plugin-transform-runtime@^7.0.0", "@babel/plugin-transform-runtime@^7.16.4": 1068 + "@babel/plugin-transform-runtime@^7.0.0", "@babel/plugin-transform-runtime@^7.12.1", "@babel/plugin-transform-runtime@^7.16.4": 1069 1069 version "7.19.6" 1070 1070 resolved "https://registry.yarnpkg.com/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.19.6.tgz#9d2a9dbf4e12644d6f46e5e75bfbf02b5d6e9194" 1071 1071 integrity sha512-PRH37lz4JU156lYFW1p8OxE5i7d6Sl/zV58ooyr+q1J1lnQPyg5tIiXlIwNVhJaY4W3TmOtdc8jqdXQcB1v5Yw== ··· 2088 2088 dependencies: 2089 2089 semver "7.3.8" 2090 2090 2091 + "@miblanchard/react-native-slider@^2.2.0": 2092 + version "2.2.0" 2093 + resolved "https://registry.yarnpkg.com/@miblanchard/react-native-slider/-/react-native-slider-2.2.0.tgz#5d03cf49516ad0a3b4011fbcad53cb379800832b" 2094 + integrity sha512-LepVGFVy6KtDVgMRIAAJJKQCXbcADkzK2R61t3LkD+IF2wG1J4I4KVo99GAsvU0EBKeCsjHPkR+6LGnB6xGzVA== 2095 + 2091 2096 "@nicolo-ribaudo/eslint-scope-5-internals@5.1.1-v1": 2092 2097 version "5.1.1-v1" 2093 2098 resolved "https://registry.yarnpkg.com/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz#dbf733a965ca47b1973177dc0bb6c889edcfb129" ··· 2900 2905 resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.4.tgz#cd667bcfdd025213aafb7ca5915a932590acdcdc" 2901 2906 integrity sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw== 2902 2907 2908 + "@types/react-avatar-editor@^13.0.0": 2909 + version "13.0.0" 2910 + resolved "https://registry.yarnpkg.com/@types/react-avatar-editor/-/react-avatar-editor-13.0.0.tgz#5963e16c931746c47e478d669dd72d388b427393" 2911 + integrity sha512-5ymOayy6mfT35xTqzni7UjXvCNEg8/pH4pI5RenITp9PBc02KGTYjSV1WboXiQDYSh5KomLT0ngBLEAIhV1QoQ== 2912 + dependencies: 2913 + "@types/react" "*" 2914 + 2903 2915 "@types/react-native@^0.67.3": 2904 2916 version "0.67.17" 2905 2917 resolved "https://registry.yarnpkg.com/@types/react-native/-/react-native-0.67.17.tgz#afebc3fff1d6314840c13b7936e17fa350eb7aae" ··· 2914 2926 dependencies: 2915 2927 "@types/react" "^17" 2916 2928 2917 - "@types/react@^17": 2929 + "@types/react@*", "@types/react@^17": 2918 2930 version "17.0.52" 2919 2931 resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.52.tgz#10d8b907b5c563ac014a541f289ae8eaa9bf2e9b" 2920 2932 integrity sha512-vwk8QqVODi0VaZZpDXQCmEmiOuyjEFPY7Ttaw5vjM112LOq37yz1CDJGrRJwA1fYEq4Iitd5rnjd1yWAc/bT+A== ··· 11161 11173 raf "^3.4.1" 11162 11174 regenerator-runtime "^0.13.9" 11163 11175 whatwg-fetch "^3.6.2" 11176 + 11177 + react-avatar-editor@^13.0.0: 11178 + version "13.0.0" 11179 + resolved "https://registry.yarnpkg.com/react-avatar-editor/-/react-avatar-editor-13.0.0.tgz#55013625ee9ae715c1fe2dc553b8079994d8a5f2" 11180 + integrity sha512-0xw63MbRRQdDy7YI1IXU9+7tTFxYEFLV8CABvryYOGjZmXRTH2/UA0mafe57ns62uaEFX181kA4XlGlxCaeXKA== 11181 + dependencies: 11182 + "@babel/plugin-transform-runtime" "^7.12.1" 11183 + "@babel/runtime" "^7.12.5" 11184 + prop-types "^15.7.2" 11164 11185 11165 11186 react-circular-progressbar@^2.1.0: 11166 11187 version "2.1.0"