├── .gitignore ├── babel.config.js ├── SECURITY.md ├── .eslintrc.json ├── LICENSE ├── package.json ├── README.md └── index.js /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode/ 2 | node_modules/ 3 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = function (api) { 2 | api.cache(true); 3 | return { 4 | presets: [ 'babel-preset-expo' ], 5 | }; 6 | }; 7 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | Use this section to tell people about which versions of your project are 6 | currently being supported with security updates. 7 | 8 | | Version | Supported | 9 | | ------- | ------------------ | 10 | | 1.x.x | :white_check_mark: | 11 | 12 | ## Reporting a Vulnerability 13 | 14 | Please report vulnerabilities in the issues tab on Github. 15 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "eslint:recommended", 4 | "plugin:react/recommended" 5 | ], 6 | "plugins": [ 7 | "react", 8 | "react-native" 9 | ], 10 | "parserOptions": { 11 | "ecmaFeatures": { 12 | "jsx": true 13 | }, 14 | "sourceType": "module", 15 | "ecmaVersion": 2020 16 | }, 17 | "parser": "babel-eslint", 18 | "rules": { 19 | "react/prop-types": 0, 20 | "semi": [ 21 | "error", 22 | "always" 23 | ], 24 | "eol-last": [ 25 | "error", 26 | "always" 27 | ], 28 | "indent": [ 29 | "error", 30 | 2 31 | ], 32 | "linebreak-style": [ 33 | "error", 34 | "unix" 35 | ], 36 | "array-bracket-spacing": [ 37 | "error", 38 | "always" 39 | ] 40 | }, 41 | "env": { 42 | "react-native/react-native": true 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Q Vault LLC 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-native-expo-cached-image", 3 | "version": "1.3.1", 4 | "description": "Cached image component for Expo's managed workflow", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo 'We should write tests someday'", 8 | "lint": "eslint ./ --ext .js" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/lane-c-wagner/react-native-expo-cached-image.git" 13 | }, 14 | "keywords": [ 15 | "react", 16 | "native", 17 | "expo", 18 | "cache", 19 | "image" 20 | ], 21 | "author": "lane-c-wagner", 22 | "license": "SEE LICENSE IN ", 23 | "bugs": { 24 | "url": "https://github.com/lane-c-wagner/react-native-expo-cached-image/issues" 25 | }, 26 | "homepage": "https://github.com/lane-c-wagner/react-native-expo-cached-image", 27 | "dependencies": { 28 | "expo-crypto": ">8.0.0", 29 | "expo-file-system": ">8.0.0", 30 | "react": ">16.0.0", 31 | "react-native": ">0.37.0" 32 | }, 33 | "devDependencies": { 34 | "babel-eslint": "^10.0.3", 35 | "babel-preset-expo": "^8.0.0", 36 | "eslint": "^7.5.0", 37 | "eslint-plugin-react": "^7.18.3", 38 | "eslint-plugin-react-native": "^3.8.1" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-native-expo-cached-image 2 | 3 | [![npm version](https://badge.fury.io/js/react-native-expo-cached-image.svg)](https://badge.fury.io/js/react-native-expo-cached-image) 4 | 5 | Cached image component for Expo's managed workflow. 6 | 7 | ## Deprecation warning 8 | 9 | I don't have the time or interest to maintain this little package anymore. Feel free to get some ideas from the code, but I don't recommend installing it directly. Good luck! 10 | 11 | ## ⚙️ Installation 12 | 13 | `yarn add react-native-expo-cached-image` 14 | 15 | ## 🚀 Quick Start 16 | 17 | ```javascript 18 | import CachedImage from 'react-native-expo-cached-image'; 19 | 20 | // In render() 21 | 22 | 23 | ``` 24 | 25 | The CachedImage component downloads the image to the user's local filesystem using a deterministic hash 26 | of the URI as the path key. If the image is already downloaded, it will be rendered without re-downloading. 27 | 28 | ### Props 29 | 30 | CachedImage is a direct wrapper of the standard [React Native Image](https://facebook.github.io/react-native/docs/image) 31 | and matches it's API. As such, all of the standard props are available as props to CachedImage. Styles are also passed down. 32 | 33 | #### ImageBackground 34 | 35 | CachedImage can optionally be used as a wrapper of [React Native's ImageBackground](https://facebook.github.io/react-native/docs/imagebackground). To do so, pass in the prop isBackground={true}. 36 | 37 | ```javascript 38 | import CachedImage from 'react-native-expo-cached-image'; 39 | 40 | // In render() 41 | 42 | 43 | ``` 44 | 45 | ## 💬 Contact 46 | 47 | [![Twitter Follow](https://img.shields.io/twitter/follow/wagslane.svg?label=Follow%20Wagslane&style=social)](https://twitter.com/intent/follow?screen_name=wagslane) 48 | 49 | Submit an issue (above in the issues tab) 50 | 51 | ## 🙏🏻 Compatibility 52 | 53 | CachedImage Has been tested with the react-native Expo managed workflow. If you have success with other workflows let us know! 54 | 55 | ## 👏 Contributing 56 | 57 | We love help! Contribute by forking the repo and opening pull requests. 58 | 59 | Please ensure that your code passes the existing tests and linting. Write tests to test your changes if applicable. 60 | 61 | Don't make stylistic or whitespace changes without contacting maintainers - we probably won't approve unsolicited stylistic changes. 62 | 63 | All pull requests should be submitted to the "master" branch. 64 | 65 | ```bash 66 | yarn lint 67 | ``` 68 | 69 | ```bash 70 | yarn test 71 | ``` 72 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { Image, ImageBackground, InteractionManager } from 'react-native'; 3 | import * as FileSystem from 'expo-file-system'; 4 | import * as Crypto from 'expo-crypto'; 5 | 6 | export default class CachedImage extends Component { 7 | mounted = true; 8 | state = { 9 | imgURI: '', 10 | }; 11 | 12 | async componentDidMount() { 13 | this._interaction = InteractionManager.runAfterInteractions(async () => { 14 | if (this.props.source.uri) { 15 | const filesystemURI = await this.getImageFilesystemKey(this.props.source.uri); 16 | await this.loadImage(filesystemURI, this.props.source.uri); 17 | } 18 | }); 19 | } 20 | 21 | async componentDidUpdate() { 22 | if (this.props.source.uri) { 23 | const filesystemURI = await this.getImageFilesystemKey(this.props.source.uri); 24 | if (this.props.source.uri === this.state.imgURI || filesystemURI === this.state.imgURI) { 25 | return null; 26 | } 27 | await this.loadImage(filesystemURI, this.props.source.uri); 28 | } 29 | } 30 | 31 | async componentWillUnmount() { 32 | this._interaction && this._interaction.cancel(); 33 | this.mounted = false; 34 | await this.checkClear(); 35 | } 36 | 37 | async checkClear() { 38 | try { 39 | if (this.downloadResumable) { 40 | const t = await this.downloadResumable.pauseAsync(); 41 | const filesystemURI = await this.getImageFilesystemKey(this.props.source.uri); 42 | const metadata = await FileSystem.getInfoAsync(filesystemURI); 43 | if (metadata.exists) { 44 | await FileSystem.deleteAsync(t.fileUri); 45 | } 46 | } 47 | } catch (error) { 48 | console.log(error); 49 | } 50 | } 51 | 52 | async getImageFilesystemKey(remoteURI) { 53 | const hashed = await Crypto.digestStringAsync(Crypto.CryptoDigestAlgorithm.SHA256, remoteURI); 54 | return `${FileSystem.documentDirectory}${hashed}`; 55 | } 56 | 57 | async loadImage(filesystemURI, remoteURI) { 58 | if (this.downloadResumable && this.downloadResumable._removeSubscription) { 59 | this.downloadResumable._removeSubscription(); 60 | } 61 | try { 62 | // Use the cached image if it exists 63 | const metadata = await FileSystem.getInfoAsync(filesystemURI); 64 | if (metadata.exists) { 65 | this.setState({ 66 | imgURI: filesystemURI, 67 | }); 68 | return; 69 | } 70 | 71 | // otherwise download to cache 72 | this.downloadResumable = FileSystem.createDownloadResumable( 73 | remoteURI, 74 | filesystemURI, 75 | {}, 76 | (dp) => this.onDownloadUpdate(dp) 77 | ); 78 | 79 | const imageObject = await this.downloadResumable.downloadAsync(); 80 | if (this.mounted) { 81 | if (imageObject && imageObject.status == '200') { 82 | this.setState({ 83 | imgURI: imageObject.uri, 84 | }); 85 | } 86 | } 87 | } catch (err) { 88 | console.log('Image download error:', err); 89 | if (this.mounted) { 90 | this.setState({ imgURI: null }); 91 | } 92 | const metadata = await FileSystem.getInfoAsync(filesystemURI); 93 | if (metadata.exists) { 94 | await FileSystem.deleteAsync(filesystemURI); 95 | } 96 | } 97 | } 98 | 99 | onDownloadUpdate(downloadProgress) { 100 | if (downloadProgress.totalBytesWritten >= downloadProgress.totalBytesExpectedToWrite) { 101 | if (this.downloadResumable && this.downloadResumable._removeSubscription) { 102 | this.downloadResumable._removeSubscription(); 103 | } 104 | this.downloadResumable = null; 105 | } 106 | } 107 | 108 | render() { 109 | let source = this.state.imgURI ? { uri: this.state.imgURI } : null; 110 | if (!source && this.props.source) { 111 | source = { ...this.props.source, cache: 'force-cache' }; 112 | } 113 | if (this.props.isBackground) { 114 | return ( 115 | 116 | {this.props.children} 117 | 118 | ); 119 | } else { 120 | return ; 121 | } 122 | } 123 | } 124 | --------------------------------------------------------------------------------