Fix photo & camera permission management (#140)
* Check photo & camera perms and alert the user if not available (close #64) - Adds perms checks with a prompt to update settings if needed - Moves initial access of photos in the composer so that the initial prompt occurs at an intuitive time. * Add react-native-permissions test mock * Fix issue causing multiple access requests * Use longer var names * Update podfile.lock * Lint fix * Move photo perm request in composer to the gallery btn instead of when the carousel is opened
This commit is contained in:
@@ -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<boolean> {
|
||||
const status = await check(perm)
|
||||
return isntANo(status)
|
||||
}
|
||||
|
||||
export async function requestAccessIfNeeded(
|
||||
perm: Permission,
|
||||
): Promise<boolean> {
|
||||
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
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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<UserLocalPhotosModel>(
|
||||
() => 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({
|
||||
/>
|
||||
)}
|
||||
</ScrollView>
|
||||
{isSelectingPhotos &&
|
||||
localPhotos.photos != null &&
|
||||
selectedPhotos.length < 4 ? (
|
||||
{isSelectingPhotos && selectedPhotos.length < 4 ? (
|
||||
<PhotoCarouselPicker
|
||||
selectedPhotos={selectedPhotos}
|
||||
onSelectPhotos={onSelectPhotos}
|
||||
localPhotos={localPhotos}
|
||||
/>
|
||||
) : !extLink &&
|
||||
selectedPhotos.length === 0 &&
|
||||
|
||||
@@ -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<boolean>(false)
|
||||
|
||||
const localPhotos = React.useMemo<UserLocalPhotosModel>(
|
||||
() => 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}>
|
||||
<FontAwesomeIcon icon="image" style={pal.link} 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>
|
||||
))}
|
||||
{isSetup &&
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user