diff --git a/ios/Podfile b/ios/Podfile index aa0cb6655c..2e80c1455b 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -44,6 +44,11 @@ target 'app' do :app_path => "#{Pod::Config.instance.installation_root}/.." ) + # react-native-permissions settings + permissions_path = '../node_modules/react-native-permissions/ios' + pod 'Permission-Camera', :path => "#{permissions_path}/Camera" + pod 'Permission-PhotoLibrary', :path => "#{permissions_path}/PhotoLibrary" + target 'appTests' do inherit! :complete # Pods for testing diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 1df1a99cf5..0811f94a63 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -26,6 +26,10 @@ PODS: - libwebp/mux (1.2.4): - libwebp/demux - libwebp/webp (1.2.4) + - Permission-Camera (3.6.1): + - RNPermissions + - Permission-PhotoLibrary (3.6.1): + - RNPermissions - RCT-Folly (2021.07.22.00): - boost - DoubleConversion @@ -379,6 +383,8 @@ PODS: - RNNotifee/NotifeeCore (= 7.4.0) - RNNotifee/NotifeeCore (7.4.0): - React-Core + - RNPermissions (3.6.1): + - React-Core - RNReactNativeHapticFeedback (1.14.0): - React-Core - RNReanimated (2.13.0): @@ -437,6 +443,8 @@ DEPENDENCIES: - glog (from `../node_modules/react-native/third-party-podspecs/glog.podspec`) - hermes-engine (from `../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`) - libevent (~> 2.1.12) + - Permission-Camera (from `../node_modules/react-native-permissions/ios/Camera`) + - Permission-PhotoLibrary (from `../node_modules/react-native-permissions/ios/PhotoLibrary`) - RCT-Folly (from `../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec`) - RCTRequired (from `../node_modules/react-native/Libraries/RCTRequired`) - RCTTypeSafety (from `../node_modules/react-native/Libraries/TypeSafety`) @@ -483,6 +491,7 @@ DEPENDENCIES: - RNImageCropPicker (from `../node_modules/react-native-image-crop-picker`) - RNInAppBrowser (from `../node_modules/react-native-inappbrowser-reborn`) - "RNNotifee (from `../node_modules/@notifee/react-native`)" + - RNPermissions (from `../node_modules/react-native-permissions`) - RNReactNativeHapticFeedback (from `../node_modules/react-native-haptic-feedback`) - RNReanimated (from `../node_modules/react-native-reanimated`) - RNScreens (from `../node_modules/react-native-screens`) @@ -516,6 +525,10 @@ EXTERNAL SOURCES: :podspec: "../node_modules/react-native/third-party-podspecs/glog.podspec" hermes-engine: :podspec: "../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec" + Permission-Camera: + :path: "../node_modules/react-native-permissions/ios/Camera" + Permission-PhotoLibrary: + :path: "../node_modules/react-native-permissions/ios/PhotoLibrary" RCT-Folly: :podspec: "../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec" RCTRequired: @@ -606,6 +619,8 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native-inappbrowser-reborn" RNNotifee: :path: "../node_modules/@notifee/react-native" + RNPermissions: + :path: "../node_modules/react-native-permissions" RNReactNativeHapticFeedback: :path: "../node_modules/react-native-haptic-feedback" RNReanimated: @@ -632,6 +647,8 @@ SPEC CHECKSUMS: hermes-engine: f6e715aa6c8bd38de6c13bc85e07b0a337edaa89 libevent: 4049cae6c81cdb3654a443be001fb9bdceff7913 libwebp: f62cb61d0a484ba548448a4bd52aabf150ff6eef + Permission-Camera: bf6791b17c7f614b6826019fcfdcc286d3a107f6 + Permission-PhotoLibrary: 5b34ca67279f7201ae109cef36f9806a6596002d RCT-Folly: 424b8c9a7a0b9ab2886ffe9c3b041ef628fd4fb1 RCTRequired: fd4d923b964658aa0c4091a32c8b2004c6d9e3a6 RCTTypeSafety: c276d85975bde3d8448907235c70bf0da257adfd @@ -677,6 +694,7 @@ SPEC CHECKSUMS: RNImageCropPicker: 648356d68fbf9911a1016b3e3723885d28373eda RNInAppBrowser: e36d6935517101ccba0e875bac8ad7b0cb655364 RNNotifee: da8dcf09f079ea22f46e239d7c406e10d4525a5f + RNPermissions: dcdb7b99796bbeda6975a6e79ad519c41b251b1c RNReactNativeHapticFeedback: 1e3efeca9628ff9876ee7cdd9edec1b336913f8c RNReanimated: d8d9d3d3801bda5e35e85cdffc871577d044dc2e RNScreens: 34cc502acf1b916c582c60003dc3089fa01dc66d @@ -689,6 +707,6 @@ SPEC CHECKSUMS: TOCropViewController: edfd4f25713d56905ad1e0b9f5be3fbe0f59c863 Yoga: 921eb014669cf9c718ada68b08d362517d564e0c -PODFILE CHECKSUM: 32d3f4ec61d2c786d46fd58000f53917922dbb8a +PODFILE CHECKSUM: 95c7fde1130d862b561348cca2b3fb7f9bd84bfb COCOAPODS: 1.11.3 diff --git a/jest/jestSetup.js b/jest/jestSetup.js index 35bb1772fd..d7b0dcc575 100644 --- a/jest/jestSetup.js +++ b/jest/jestSetup.js @@ -55,3 +55,7 @@ jest.mock('@segment/analytics-react-native', () => ({ flush: jest.fn(), }), })) + +jest.mock('react-native-permissions', () => + require('react-native-permissions/mock'), +) diff --git a/package.json b/package.json index 71a4944621..02ba7fa645 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "react-native-inappbrowser-reborn": "^3.6.3", "react-native-linear-gradient": "^2.6.2", "react-native-pager-view": "^6.0.2", + "react-native-permissions": "^3.6.1", "react-native-progress": "^5.0.0", "react-native-reanimated": "^2.9.1", "react-native-root-siblings": "^4.1.1", diff --git a/src/lib/permissions.ts b/src/lib/permissions.ts new file mode 100644 index 0000000000..ab2c73ca6c --- /dev/null +++ b/src/lib/permissions.ts @@ -0,0 +1,61 @@ +import {Alert} from 'react-native' +import { + check, + openSettings, + Permission, + PermissionStatus, + PERMISSIONS, + RESULTS, +} from 'react-native-permissions' + +export const PHOTO_LIBRARY = PERMISSIONS.IOS.PHOTO_LIBRARY +export const CAMERA = PERMISSIONS.IOS.CAMERA + +/** + * Returns `true` if the user has granted permission or hasn't made + * a decision yet. Returns `false` if unavailable or not granted. + */ +export async function hasAccess(perm: Permission): Promise { + const status = await check(perm) + return isntANo(status) +} + +export async function requestAccessIfNeeded( + perm: Permission, +): Promise { + if (await hasAccess(perm)) { + return true + } + let permDescription + if (perm === PHOTO_LIBRARY) { + permDescription = 'photo library' + } else if (perm === CAMERA) { + permDescription = 'camera' + } else { + return false + } + Alert.alert( + 'Permission needed', + `Bluesky does not have permission to access your ${permDescription}.`, + [ + { + text: 'Cancel', + style: 'cancel', + }, + {text: 'Open Settings', onPress: () => openSettings()}, + ], + ) + return false +} + +export async function requestPhotoAccessIfNeeded() { + return requestAccessIfNeeded(PHOTO_LIBRARY) +} + +export async function requestCameraAccessIfNeeded() { + return requestAccessIfNeeded(CAMERA) +} + +function isntANo(status: PermissionStatus): boolean { + return status !== RESULTS.UNAVAILABLE && status !== RESULTS.BLOCKED +} diff --git a/src/state/models/user-local-photos.ts b/src/state/models/user-local-photos.ts index 08b2b39018..a2ad26ac23 100644 --- a/src/state/models/user-local-photos.ts +++ b/src/state/models/user-local-photos.ts @@ -20,7 +20,7 @@ export class UserLocalPhotosModel { } private async _getPhotos() { - CameraRoll.getPhotos({first: 20}).then(r => { + return CameraRoll.getPhotos({first: 20}).then(r => { runInAction(() => { this.photos = r.edges }) diff --git a/src/view/com/composer/ComposePost.tsx b/src/view/com/composer/ComposePost.tsx index 4e3e43e38a..11ca9c0520 100644 --- a/src/view/com/composer/ComposePost.tsx +++ b/src/view/com/composer/ComposePost.tsx @@ -43,7 +43,6 @@ import { } from '../../../lib/strings' import {getLinkMeta} from '../../../lib/link-meta' import {downloadAndResize} from '../../../lib/images' -import {UserLocalPhotosModel} from '../../../state/models/user-local-photos' import {getMentionAt, insertMentionAt} from '../../../lib/strings/mention-manip' import {PhotoCarouselPicker, cropPhoto} from './PhotoCarouselPicker' import {SelectedPhoto} from './SelectedPhoto' @@ -94,10 +93,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 @@ -112,8 +107,7 @@ export const ComposePost = observer(function ComposePost({ // initial setup useEffect(() => { autocompleteView.setup() - localPhotos.setup() - }, [autocompleteView, localPhotos]) + }, [autocompleteView]) // external link metadata-fetch flow useEffect(() => { @@ -436,13 +430,10 @@ export const ComposePost = observer(function ComposePost({ /> )} - {isSelectingPhotos && - localPhotos.photos != null && - selectedPhotos.length < 4 ? ( + {isSelectingPhotos && selectedPhotos.length < 4 ? ( ) : !extLink && selectedPhotos.length === 0 && diff --git a/src/view/com/composer/PhotoCarouselPicker.tsx b/src/view/com/composer/PhotoCarouselPicker.tsx index 689e8579d5..7057330375 100644 --- a/src/view/com/composer/PhotoCarouselPicker.tsx +++ b/src/view/com/composer/PhotoCarouselPicker.tsx @@ -12,6 +12,10 @@ import { UserLocalPhotosModel, PhotoIdentifier, } from '../../../state/models/user-local-photos' +import { + requestPhotoAccessIfNeeded, + requestCameraAccessIfNeeded, +} from '../../../lib/permissions' import {compressIfNeeded, scaleDownDimensions} from '../../../lib/images' import {usePalette} from '../../lib/hooks/usePalette' import {useStores} from '../../../state' @@ -67,16 +71,31 @@ 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 [isSetup, setIsSetup] = React.useState(false) + + const localPhotos = React.useMemo( + () => new UserLocalPhotosModel(store), + [store], + ) + + React.useEffect(() => { + // initial setup + localPhotos.setup().then(() => { + setIsSetup(true) + }) + }, [localPhotos]) + const handleOpenCamera = useCallback(async () => { try { + if (!(await requestCameraAccessIfNeeded())) { + return + } const cameraRes = await openCamera({ mediaType: 'photo', cropping: true, @@ -107,34 +126,36 @@ export const PhotoCarouselPicker = ({ [store.log, selectedPhotos, onSelectPhotos], ) - const handleOpenGallery = useCallback(() => { - openPicker({ + const handleOpenGallery = useCallback(async () => { + if (!(await requestPhotoAccessIfNeeded())) { + return + } + const items = await openPicker({ 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({ - mediaType: 'photo', - path: image.path, - ...IMAGE_PARAMS, - width, - height, - }) - const finalImg = await compressIfNeeded(cropperRes, MAX_SIZE) - const permanentPath = await moveToPremanantPath(finalImg.path) - result.push(permanentPath) - } - onSelectPhotos([...selectedPhotos, ...result]) }) + 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({ + mediaType: 'photo', + path: image.path, + ...IMAGE_PARAMS, + width, + height, + }) + const finalImg = await compressIfNeeded(cropperRes, MAX_SIZE) + const permanentPath = await moveToPremanantPath(finalImg.path) + result.push(permanentPath) + } + onSelectPhotos([...selectedPhotos, ...result]) }, [selectedPhotos, onSelectPhotos]) return ( @@ -156,15 +177,16 @@ export const PhotoCarouselPicker = ({ onPress={handleOpenGallery}> - {localPhotos.photos.map((item: PhotoIdentifier, index: number) => ( - handleSelectPhoto(item)}> - - - ))} + {isSetup && + localPhotos.photos.map((item: PhotoIdentifier, index: number) => ( + handleSelectPhoto(item)}> + + + ))} ) } diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx index 424c1e720d..c118682c8c 100644 --- a/src/view/com/util/UserAvatar.tsx +++ b/src/view/com/util/UserAvatar.tsx @@ -9,6 +9,10 @@ import { openPicker, Image as PickedImage, } from 'react-native-image-crop-picker' +import { + requestPhotoAccessIfNeeded, + requestCameraAccessIfNeeded, +} from '../../../lib/permissions' import {colors, gradients} from '../../lib/styles' import {DropdownButton} from './forms/DropdownButton' import {usePalette} from '../../lib/hooks/usePalette' @@ -53,26 +57,34 @@ export function UserAvatar({ { label: 'Camera', icon: 'camera', - // TODO: dark mode icon - onPress: () => { - openCamera({ - mediaType: 'photo', - cropping: true, - width: 2000, - height: 2000, - cropperCircleOverlay: true, - forceJpg: true, // ios only - compressImageQuality: 1, - }).then(onSelectNewAvatar) + onPress: async () => { + if (!(await requestCameraAccessIfNeeded())) { + return + } + onSelectNewAvatar?.( + await openCamera({ + mediaType: 'photo', + cropping: true, + width: 2000, + height: 2000, + cropperCircleOverlay: true, + forceJpg: true, // ios only + compressImageQuality: 1, + }), + ) }, }, { label: 'Library', icon: 'image', - onPress: () => { - openPicker({ + onPress: async () => { + if (!(await requestPhotoAccessIfNeeded())) { + return + } + const item = await openPicker({ mediaType: 'photo', - }).then(async item => { + }) + onSelectNewAvatar?.( await openCropper({ mediaType: 'photo', path: item.path, @@ -81,8 +93,8 @@ export function UserAvatar({ cropperCircleOverlay: true, forceJpg: true, // ios only compressImageQuality: 1, - }).then(onSelectNewAvatar) - }) + }), + ) }, }, // TODO: Remove avatar https://github.com/bluesky-social/social-app/issues/122 diff --git a/src/view/com/util/UserBanner.tsx b/src/view/com/util/UserBanner.tsx index fdd2517d16..d2b5a22ca4 100644 --- a/src/view/com/util/UserBanner.tsx +++ b/src/view/com/util/UserBanner.tsx @@ -10,6 +10,10 @@ import { openCropper, openPicker, } from 'react-native-image-crop-picker' +import { + requestPhotoAccessIfNeeded, + requestCameraAccessIfNeeded, +} from '../../../lib/permissions' import {DropdownButton} from './forms/DropdownButton' import {usePalette} from '../../lib/hooks/usePalette' @@ -25,28 +29,36 @@ export function UserBanner({ { label: 'Camera', icon: 'camera', - // TODO: Add darkmode support https://github.com/bluesky-social/social-app/issues/78 - onPress: () => { - openCamera({ - mediaType: 'photo', - cropping: true, - compressImageMaxWidth: 6000, - width: 6000, - compressImageMaxHeight: 2000, - height: 2000, - forceJpg: true, // ios only - compressImageQuality: 1, - includeExif: true, - }).then(onSelectNewBanner) + onPress: async () => { + if (!(await requestCameraAccessIfNeeded())) { + return + } + onSelectNewBanner?.( + await openCamera({ + mediaType: 'photo', + cropping: true, + compressImageMaxWidth: 6000, + width: 6000, + compressImageMaxHeight: 2000, + height: 2000, + forceJpg: true, // ios only + compressImageQuality: 1, + includeExif: true, + }), + ) }, }, { label: 'Library', icon: 'image', - onPress: () => { - openPicker({ + onPress: async () => { + if (!(await requestPhotoAccessIfNeeded())) { + return + } + const item = await openPicker({ mediaType: 'photo', - }).then(async item => { + }) + onSelectNewBanner?.( await openCropper({ mediaType: 'photo', path: item.path, @@ -57,8 +69,8 @@ export function UserBanner({ forceJpg: true, // ios only compressImageQuality: 1, includeExif: true, - }).then(onSelectNewBanner) - }) + }), + ) }, }, // TODO: Remove banner https://github.com/bluesky-social/social-app/issues/122 diff --git a/yarn.lock b/yarn.lock index 80796348be..e60b185641 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11133,6 +11133,11 @@ react-native-pager-view@^6.0.2: resolved "https://registry.yarnpkg.com/react-native-pager-view/-/react-native-pager-view-6.1.2.tgz#3522079b9a9d6634ca5e8d153bc0b4d660254552" integrity sha512-qs2KSFc+7N7B+UZ6SG2sTvCkppagm5fVyRclv1KFKc7lDtrhXLzN59tXJw575LDP/dRJoXsNwqUAhZJdws6ABQ== +react-native-permissions@^3.6.1: + version "3.6.1" + resolved "https://registry.yarnpkg.com/react-native-permissions/-/react-native-permissions-3.6.1.tgz#73adcc1cef8cd57a9ef167b4507405f4ff5749c4" + integrity sha512-fzPpPQXeD34inUccqtoResSwYubfrwUguP4qrVUUv8+KSMjYSaHGoS5HaIJLZHlN9gO+TvLJZ2L5ZljTsb6qnQ== + react-native-progress@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/react-native-progress/-/react-native-progress-5.0.0.tgz#f5ac6ceaeee27f184c660b00f29419e82a9d0ab0"