From b9e52a1b36db41bdcd270744efb86915c86174ac Mon Sep 17 00:00:00 2001 From: David Narbutovich <4661784+retyui@users.noreply.github.com> Date: Fri, 11 Nov 2022 08:32:28 +0100 Subject: [PATCH] feat(all): Add `useCameraRoll()` hook (#354) * Add `useCameraRoll()` hook * Bump React Native patch ver. to fix Android build --- README.md | 25 +++++++ package.json | 13 ++-- src/CameraRoll.ts | 1 + src/__tests__/useCameraRoll.ts | 120 +++++++++++++++++++++++++++++++++ src/index.ts | 1 + src/useCameraRoll.ts | 55 +++++++++++++++ yarn.lock | 35 ++++++++-- 7 files changed, 240 insertions(+), 10 deletions(-) create mode 100644 src/__tests__/useCameraRoll.ts create mode 100644 src/useCameraRoll.ts diff --git a/README.md b/README.md index a6febb8..a5494a5 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,8 @@ On Android 13 the `READ_EXTERNAL_STORAGE` has been [replace](https://developer.a * [`getAlbums`](#getalbums) * [`getPhotos`](#getphotos) * [`deletePhotos`](#deletephotos) +* [`iosGetImageDataById`](#iosgetimagedatabyid) +* [`useCameraRoll`](#usecameraroll) --- @@ -472,6 +474,29 @@ CameraRoll.iosGetImageDataById(internalID, true); | internalID | string | Yes | Ios internal ID 'PH://xxxx'. | | convertHeic | boolean | False | Whether to convert or not to JPEG image. | + + +### `useCameraRoll()` + +`useCameraRoll` is a utility hooks for the CameraRoll module. data contains the content stored in the clipboard. + +```javascript +import React, {useEffect} from 'react'; +import {Button} from 'react-native'; +import {useCameraRoll} from "@react-native-camera-roll/camera-roll"; + +function Example() { + const [photos, getPhotos, save] = useCameraRoll(); + + return <> + + { + photos.map((photo, index) => /* render photos */) + } + ; +}; +``` + ### Known issues #### IOS diff --git a/package.json b/package.json index 691c4d1..cafb3f4 100644 --- a/package.json +++ b/package.json @@ -54,29 +54,30 @@ "react-native": ">=0.59" }, "devDependencies": { - "react-native-builder-bob": "0.18.3", "@babel/core": "7.18.10", "@babel/runtime": "7.18.9", "@react-native-community/eslint-config": "3.1.0", "@semantic-release/git": "10.0.1", + "@testing-library/react-hooks": "8.0.1", + "@types/jest": "28.1.7", "@types/react-native": "0.62.10", "babel-core": "7.0.0-bridge.0", - "babel-plugin-module-resolver": "4.1.0", "babel-jest": "28.1.3", + "babel-plugin-module-resolver": "4.1.0", "detox": "19.9.3", "eslint": "8.22.0", "eslint-plugin-prettier": "4.2.1", "husky": "8.0.1", "jest": "28.1.3", - "@types/jest": "28.1.7", - "prettier": "2.7.1", "metro-react-native-babel-preset": "0.72.0", + "prettier": "2.7.1", "pretty-quick": "3.1.3", "react": "17.0.2", - "react-native": "0.66.0", + "react-native": "0.66.5", + "react-native-builder-bob": "0.18.3", "react-test-renderer": "17.0.2", - "semantic-release": "19.0.3", "rimraf": "3.0.2", + "semantic-release": "19.0.3", "typescript": "4.7.4" }, "react-native-builder-bob": { diff --git a/src/CameraRoll.ts b/src/CameraRoll.ts index cee9b3d..b0769c6 100644 --- a/src/CameraRoll.ts +++ b/src/CameraRoll.ts @@ -193,6 +193,7 @@ export class CameraRoll { ); return CameraRoll.save(tag, {type}); } + static getAlbums( params: GetAlbumsParams = {assetType: 'All'}, ): Promise { diff --git a/src/__tests__/useCameraRoll.ts b/src/__tests__/useCameraRoll.ts new file mode 100644 index 0000000..140c93c --- /dev/null +++ b/src/__tests__/useCameraRoll.ts @@ -0,0 +1,120 @@ +import {renderHook} from '@testing-library/react-hooks'; +import {useCameraRoll} from '../useCameraRoll'; +import RNCCameraRoll from '../nativeInterface'; + +jest.mock('../nativeInterface', () => ({ + getPhotos: jest.fn(), + saveToCameraRoll: jest.fn(), +})); + +describe('useCameraRoll()', () => { + it('should return initial photos by default', () => { + const {result} = renderHook(() => useCameraRoll()); + + expect(result.current).toEqual([ + { + edges: [], + page_info: {end_cursor: '', has_next_page: false, start_cursor: ''}, + }, + expect.any(Function), + expect.any(Function), + ]); + }); + + describe('saveToCameraRoll()', () => { + it('should invoke save with passed params', () => { + const tag = 'mock-tag'; + const type = 'video'; + const album = 'test-album'; + const {result} = renderHook(() => useCameraRoll()); + const [, , saveToCameraRoll] = result.current; + + RNCCameraRoll.saveToCameraRoll.mockResolvedValueOnce(''); + saveToCameraRoll(tag, {type, album}); + + expect(RNCCameraRoll.saveToCameraRoll).toBeCalledWith(tag, {album, type}); + }); + }); + + describe('getPhotos()', () => { + const createPhotosMock = ({ + edges = [] as Array<{node: {type: string}}>, + has_next_page = false, + start_cursor = '', + end_cursor = '', + limited = false, + } = {}) => ({ + edges, + limited, + page_info: {has_next_page, start_cursor, end_cursor}, + }); + + it('should invoke getPhotos with default params', async () => { + const {result, waitForNextUpdate} = renderHook(() => useCameraRoll()); + const [, getPhotos] = result.current; + + RNCCameraRoll.getPhotos.mockResolvedValueOnce(createPhotosMock()); + getPhotos(); + + await waitForNextUpdate(); + + expect(RNCCameraRoll.getPhotos).toHaveBeenCalledWith({ + assetType: 'All', + first: 20, + groupTypes: 'All', + }); + }); + + it('should invoke getPhotos with custom params', async () => { + const customParams = { + first: 1, + assetType: 'Photos' as const, + include: ['filename' as const], + }; + const {result, waitForNextUpdate} = renderHook(() => useCameraRoll()); + const [, getPhotos] = result.current; + + RNCCameraRoll.getPhotos.mockResolvedValueOnce(createPhotosMock()); + getPhotos(customParams); + + await waitForNextUpdate(); + + expect(RNCCameraRoll.getPhotos).toHaveBeenCalledWith({ + assetType: 'Photos', + first: 1, + groupTypes: 'All', + include: ['filename'], + }); + }); + + it('should return result of getPhotos', async () => { + const mockPhotos = createPhotosMock({ + edges: [{node: {type: 'mock-type'}}], + }); + const {result, waitForNextUpdate} = renderHook(() => useCameraRoll()); + const [, getPhotos] = result.current; + + RNCCameraRoll.getPhotos.mockResolvedValueOnce(mockPhotos); + getPhotos(); + + await waitForNextUpdate(); + + const [photos] = result.current; + + expect(photos).toEqual(mockPhotos); + }); + + it('should handle an error when invoke getPhotos', async () => { + const error = new Error('Ops...'); + const {result} = renderHook(() => useCameraRoll()); + const [initialPhotos, getPhotos] = result.current; + + RNCCameraRoll.getPhotos.mockRejectedValueOnce(error); + getPhotos(); + + const [afterError] = result.current; + + expect(initialPhotos).toBe(afterError); + }); + }); +}); diff --git a/src/index.ts b/src/index.ts index ef7be36..62bbb32 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,2 +1,3 @@ +export * from './useCameraRoll'; export * from './CameraRoll'; export * from './CameraRollIOSPermission'; diff --git a/src/useCameraRoll.ts b/src/useCameraRoll.ts new file mode 100644 index 0000000..8a9d621 --- /dev/null +++ b/src/useCameraRoll.ts @@ -0,0 +1,55 @@ +import {useState} from 'react'; +import type { + GetPhotosParams, + PhotoIdentifiersPage, + SaveToCameraRollOptions, +} from './CameraRoll'; +import {CameraRoll} from './CameraRoll'; + +const initialState: PhotoIdentifiersPage = { + edges: [], + page_info: { + end_cursor: '', + has_next_page: false, + start_cursor: '', + }, +}; + +const defaultConfig: GetPhotosParams = { + first: 20, + groupTypes: 'All', +}; + +type UseCameraRollResult = [ + PhotoIdentifiersPage, + (config?: GetPhotosParams) => Promise, + (tag: string, options?: SaveToCameraRollOptions) => Promise, +]; + +export function useCameraRoll(): UseCameraRollResult { + const [photos, setPhotos] = useState(initialState); + + async function getPhotos( + config: GetPhotosParams = defaultConfig, + ): Promise { + try { + const result = await CameraRoll.getPhotos(config); + setPhotos(result); + } catch (error) { + if (__DEV__) console.log('[useCameraRoll] Error getting photos: ', error); + } + } + + async function save( + ...args: Parameters + ): Promise { + try { + await CameraRoll.save(...args); + } catch (error) { + if (__DEV__) + console.log('[useCameraRoll] Error saving to camera roll: ', error); + } + } + + return [photos, getPhotos, save]; +} diff --git a/yarn.lock b/yarn.lock index 7dab313..362fad4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1761,6 +1761,13 @@ dependencies: regenerator-runtime "^0.13.4" +"@babel/runtime@^7.12.5": + version "7.20.1" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.20.1.tgz#1148bb33ab252b165a06698fde7576092a78b4a9" + integrity sha512-mrzLkl6U9YLF8qpqI7TB82PESyEGjm/0Ly91jG575eVxMMlb8fYfOXFZIJ8XfLrJZQbm7dlKry2bJmXBUEkdFg== + dependencies: + regenerator-runtime "^0.13.10" + "@babel/template@^7.0.0", "@babel/template@^7.1.0", "@babel/template@^7.2.2": version "7.2.2" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.2.2.tgz#005b3fdf0ed96e88041330379e0da9a708eb2907" @@ -2856,6 +2863,14 @@ dependencies: "@sinonjs/commons" "^1.7.0" +"@testing-library/react-hooks@8.0.1": + version "8.0.1" + resolved "https://registry.yarnpkg.com/@testing-library/react-hooks/-/react-hooks-8.0.1.tgz#0924bbd5b55e0c0c0502d1754657ada66947ca12" + integrity sha512-Aqhl2IVmLt8IovEVarNDFuJDVWVvhnr9/GCU6UUnrYXwgDFF9h2L2o2P9KBni1AST5sT6riAyoukFLyjQUgD/g== + dependencies: + "@babel/runtime" "^7.12.5" + react-error-boundary "^3.1.0" + "@tootallnate/once@2": version "2.0.0" resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-2.0.0.tgz#f544a148d3ab35801c1f633a7441fd87c2e484bf" @@ -9338,6 +9353,13 @@ react-devtools-core@^4.13.0: shell-quote "^1.6.1" ws "^7" +react-error-boundary@^3.1.0: + version "3.1.4" + resolved "https://registry.yarnpkg.com/react-error-boundary/-/react-error-boundary-3.1.4.tgz#255db92b23197108757a888b01e5b729919abde0" + integrity sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA== + dependencies: + "@babel/runtime" "^7.12.5" + "react-is@^16.12.0 || ^17.0.0 || ^18.0.0", react-is@^18.0.0: version "18.2.0" resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.2.0.tgz#199431eeaaa2e09f86427efbb4f1473edb47609b" @@ -9394,10 +9416,10 @@ react-native-codegen@^0.0.7: jscodeshift "^0.11.0" nullthrows "^1.1.1" -react-native@0.66.0: - version "0.66.0" - resolved "https://registry.yarnpkg.com/react-native/-/react-native-0.66.0.tgz#99bdd83a9a612a71b94242767989d666d445b007" - integrity sha512-m26TKwzsfHVdZ1hDG+7mZ4M4ftxFFZrhtiT0OXuwfBzmNtB3xhsJtYszPeizw33c9YNp8rvehKT3c4ldDCW6kA== +react-native@0.66.5: + version "0.66.5" + resolved "https://registry.yarnpkg.com/react-native/-/react-native-0.66.5.tgz#9056a2f7ad04d5e75b3a00dab847b3366d69f26a" + integrity sha512-dC5xmE1anT+m8eGU0N/gv2XUWZygii6TTqbwZPsN+uMhVvjxt4FsTqpZOFFvA5sxLPR/NDEz8uybTvItNBMClw== dependencies: "@jest/create-cache-key-function" "^27.0.1" "@react-native-community/cli" "^6.0.0" @@ -9607,6 +9629,11 @@ regenerate@^1.4.2: resolved "https://registry.yarnpkg.com/regenerate/-/regenerate-1.4.2.tgz#b9346d8827e8f5a32f7ba29637d398b69014848a" integrity sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A== +regenerator-runtime@^0.13.10: + version "0.13.10" + resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.10.tgz#ed07b19616bcbec5da6274ebc75ae95634bfc2ee" + integrity sha512-KepLsg4dU12hryUO7bp/axHAKvwGOCV0sGloQtpagJ12ai+ojVDqkeGSiRX1zlq+kjIMZ1t7gpze+26QqtdGqw== + regenerator-runtime@^0.13.2, regenerator-runtime@^0.13.4: version "0.13.5" resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.5.tgz#d878a1d094b4306d10b9096484b33ebd55e26697"