From d6299413b8d35d3700f26a28ed97c4fc0fc6697f Mon Sep 17 00:00:00 2001 From: kfiroo Date: Sat, 3 Dec 2016 20:16:55 +0200 Subject: [PATCH] initial commit --- .gitignore | 1 + CachedImage.js | 180 +++++++++++++++++++++++++++++++++++++++ ImageCacheProvider.js | 192 ++++++++++++++++++++++++++++++++++++++++++ index.js | 4 + package.json | 29 +++++++ yarn.lock | 41 +++++++++ 6 files changed, 447 insertions(+) create mode 100644 CachedImage.js create mode 100644 ImageCacheProvider.js create mode 100644 index.js create mode 100644 package.json create mode 100644 yarn.lock diff --git a/.gitignore b/.gitignore index 5148e52..a22b528 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,4 @@ jspm_packages # Optional REPL history .node_repl_history +/.idea/ diff --git a/CachedImage.js b/CachedImage.js new file mode 100644 index 0000000..bee289e --- /dev/null +++ b/CachedImage.js @@ -0,0 +1,180 @@ +'use strict'; + +const _ = require('lodash'); +const React = require('react'); +const ReactNative = require('react-native'); + +const ImageCacheProvider = require('./ImageCacheProvider'); + +const { + Image, + ActivityIndicator, + NetInfo +} = ReactNative; + +const { + StyleSheet +} = ReactNative; + +const styles = StyleSheet.create({ + image: { + backgroundColor: 'transparent' + }, + loader: { + backgroundColor: 'transparent', + flex: 1 + } +}); + +const CacheableImage = React.createClass({ + propTypes: { + renderImage: React.PropTypes.func.isRequired, + activityIndicatorProps: React.PropTypes.object.isRequired, + defaultSource: Image.propTypes.source, + useQueryParamsInCacheKey: React.PropTypes.oneOfType([ + React.PropTypes.bool, + React.PropTypes.array + ]).isRequired + }, + + getDefaultProps() { + return { + renderImage: props => (), + activityIndicatorProps: {}, + useQueryParamsInCacheKey: false + }; + }, + + getInitialState() { + this._isMounted = false; + return { + isRemote: false, + cachedImagePath: null, + jobId: null, + networkAvailable: true + }; + }, + + safeSetState(newState) { + if (!this._isMounted) { + return; + } + return this.setState(newState); + }, + + componentWillMount() { + this._isMounted = true; + NetInfo.isConnected.addEventListener('change', this.handleConnectivityChange); + // initial + NetInfo.isConnected.fetch() + .then(isConnected => { + this.safeSetState({ + networkAvailable: isConnected + }); + }); + + this.processSource(this.props.source); + }, + + componentWillUnmount() { + this._isMounted = false; + NetInfo.isConnected.removeEventListener('change', this.handleConnectivityChange); + }, + + componentWillReceiveProps(nextProps) { + if (this.props.source !== nextProps.source) { + this.processSource(nextProps.source); + } + }, + + handleConnectivityChange(isConnected) { + this.safeSetState({ + networkAvailable: isConnected + }); + }, + + processSource(source) { + const url = _.get(source, ['uri'], null); + if (ImageCacheProvider.isCacheable(url)) { + const options = _.pick(this.props, ['useQueryParamsInCacheKey']); + // try to get the image path from cache + ImageCacheProvider.getCachedImagePath(url, options) + // try to put the image in cache if + .catch(() => ImageCacheProvider.cacheImage(url, options)) + .then(cachedImagePath => { + this.safeSetState({ + cachedImagePath + }); + }) + .catch(err => { + console.log('>>> error ', err); + this.safeSetState({ + cachedImagePath: null + }); + }); + this.safeSetState({ + isRemote: true + }); + } else { + this.safeSetState({ + isRemote: false + }); + } + }, + + render() { + if (!this.state.isRemote) { + return this.renderLocal(); + } + if (this.state.cachedImagePath) { + return this.renderCache(); + } + if (this.props.defaultSource) { + return this.renderDefaultSource(); + } + return this.renderLoader(); + }, + + renderLocal() { + const props = _.omit(this.props, ['defaultSource', 'activityIndicatorProps', 'style']); + const style = this.props.style || styles.image; + return this.props.renderImage({ + ...props, + style + }); + }, + + renderCache() { + const props = _.omit(this.props, ['defaultSource', 'activityIndicatorProps', 'style']); + const style = this.props.style || styles.image; + return this.props.renderImage({ + ...props, + style, + source: { + uri: 'file://' + this.state.cachedImagePath + } + }); + }, + + renderDefaultSource() { + const {children, defaultSource, ...props} = this.props; + return ( + + {children} + + ); + }, + + renderLoader() { + const props = _.omit(this.props.activityIndicatorProps, ['style']); + const style = [this.props.style, this.props.activityIndicatorProps.style || styles.loader]; + return ( + + ); + } +}); + +module.exports = CacheableImage; diff --git a/ImageCacheProvider.js b/ImageCacheProvider.js new file mode 100644 index 0000000..adf5d7d --- /dev/null +++ b/ImageCacheProvider.js @@ -0,0 +1,192 @@ +'use strict'; + +const _ = require('lodash'); +const RNFS = require('react-native-fs'); + +const { + DocumentDirectoryPath +} = RNFS; + +const SHA1 = require("crypto-js/sha1"); +const URL = require('url-parse'); + +const defaultOptions = { + useQueryParamsInCacheKey: false +}; + +const activeDownloads = {}; + +function serializeObjectKeys(obj) { + return _(obj) + .toPairs() + .sortBy(a => a[0]) + .map(a => a[1]) + .value(); +} + +function getQueryForCacheKey(url, useQueryParamsInCacheKey) { + if (_.isArray(useQueryParamsInCacheKey)) { + return serializeObjectKeys(_.pick(url.query, useQueryParamsInCacheKey)); + } + if (useQueryParamsInCacheKey) { + return serializeObjectKeys(url.query); + } + return ''; +} + +function generateCacheKey(url, options) { + const parsedUrl = new URL(url, null, true); + const parts = parsedUrl.pathname.split('.'); + const type = parts.length > 1 ? ('.' + parts.pop()) : ''; + const pathname = parts.join('.'); + const cacheable = pathname + getQueryForCacheKey(parsedUrl, options.useQueryParamsInCacheKey); + return SHA1(cacheable) + type; +} + +function getCachePath(url, options) { + if (options.cacheGroup) { + return options.cacheGroup; + } + const parsedUrl = new URL(url); + return parsedUrl.host; +} + +function getCachedImageFilePath(url, options) { + const cacheKey = generateCacheKey(url, options); + const cachePath = getCachePath(url, options); + + const dirPath = DocumentDirectoryPath + '/' + cachePath; + return dirPath + '/' + cacheKey; +} + +function deleteFile(filePath) { + return RNFS.exists(filePath) + .then(res => res && RNFS.unlink(filePath)) + .catch((err) => { + // swallow error to always resolve + }); +} + +function ensurePath(filePath) { + const parts = filePath.split('/'); + const dirPath = _.initial(parts).join('/'); + return RNFS.mkdir(dirPath, {NSURLIsExcludedFromBackupKey: true}); +} + +/** + * returns a promise that is resolved when the download of the requested file + * is complete and the file is saved. + * if the download fails, or was stopped the partial file is deleted, and the + * promise is rejected + * @param fromUrl + * @param toFile + * @returns {Promise} + */ +function downloadImage(fromUrl, toFile) { + // use toFile as the key as is was created using the cacheKey + if (!_.has(activeDownloads, toFile)) { + // create an active download for this file + activeDownloads[toFile] = new Promise((resolve, reject) => { + const downloadOptions = { + fromUrl, + toFile + }; + RNFS.downloadFile(downloadOptions).promise + .then(() => { + resolve(toFile); + }) + .catch(err => deleteFile(toFile) + .then(() => reject(err)) + ) + .finally(() => { + // cleanup + delete activeDownloads[toFile]; + }); + }); + } + return activeDownloads[toFile]; +} + +function createPrefetcer(list) { + const urls = _.clone(list); + return { + next() { + return urls.shift(); + } + }; +} + +function runPrefetchTask(prefetcher, options) { + const url = prefetcher.next(); + if (!url) { + return Promise.resolve(); + } + // if url is cacheable - cache it + if (isCacheable(url)) { + // check cache + return getCachedImagePath(url, options) + // if not found download + .catch(() => cacheImage(url, options)) + // then run next task + .then(() => runPrefetchTask(prefetcher, options)); + } + // else get next + return runPrefetchTask(prefetcher, options); +} + +// API + +function isCacheable(url) { + return _.isString(url) && (_.startsWith(url, 'http://') || _.startsWith(url, 'https://')); +} + +function getCachedImagePath(url, options = defaultOptions) { + const filePath = getCachedImageFilePath(url, options); + return RNFS.stat(filePath) + .then(res => { + if (!res.isFile()) { + // reject the promise if res is not a file + throw new Error('Failed to get image from cache'); + } + return filePath; + }) + .catch(err => { + throw err; + }) +} + +function cacheImage(url, options = defaultOptions) { + const filePath = getCachedImageFilePath(url, options); + return ensurePath(filePath) + .then(() => downloadImage(url, filePath)); +} + +function deleteCachedImage(url, options = defaultOptions) { + const filePath = getCachedImageFilePath(url, options); + return deleteFile(filePath); +} + +function cacheMultipleImages(urls, options = defaultOptions) { + const prefetcher = createPrefetcer(urls); + const numberOfWorkers = urls.length; + const promises = _.times(numberOfWorkers, () => + runPrefetchTask(prefetcher, options) + ); + return Promise.all(promises); +} + +function deleteMultipleCachedImages(urls, options = defaultOptions) { + return _.reduce(urls, (p, url) => + p.then(() => deleteCachedImage(url, options)), + Promise.resolve() + ); +} + +module.exports = { + isCacheable, + getCachedImagePath, + cacheImage, + deleteCachedImage, + cacheMultipleImages, + deleteMultipleCachedImages +}; diff --git a/index.js b/index.js new file mode 100644 index 0000000..f874cc0 --- /dev/null +++ b/index.js @@ -0,0 +1,4 @@ +'use strict'; + +module.exports = require('./CachedImage'); +module.exports.ImageCacheProvider = require('./ImageCacheProvider'); diff --git a/package.json b/package.json new file mode 100644 index 0000000..b3961bb --- /dev/null +++ b/package.json @@ -0,0 +1,29 @@ +{ + "name": "react-native-cached-image", + "version": "1.0.0", + "description": "CachedImage component for react-native", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "postinstall": "react-native link react-native-fs" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/jayesbe/react-native-cacheable-image.git" + }, + "keywords": [ + "react-native", "cache", "image" + ], + "author": "kfiroo ", + "license": "MIT", + "bugs": { + "url": "https://github.com/jayesbe/react-native-cacheable-image/issues" + }, + "homepage": "https://github.com/jayesbe/react-native-cacheable-image#readme", + "dependencies": { + "crypto-js": "3.1.6", + "lodash": "4.17.2", + "react-native-fs": "2.0.1-rc.2", + "url-parse": "1.1.1" + } +} diff --git a/yarn.lock b/yarn.lock new file mode 100644 index 0000000..835ce64 --- /dev/null +++ b/yarn.lock @@ -0,0 +1,41 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +base-64@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/base-64/-/base-64-0.1.0.tgz#780a99c84e7d600260361511c4877613bf24f6bb" + +crypto-js@3.1.6: + version "3.1.6" + resolved "https://registry.yarnpkg.com/crypto-js/-/crypto-js-3.1.6.tgz#6142651b232dbb8ebdfa9716a70a2888359da6c9" + +lodash@4.17.2: + version "4.17.2" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.2.tgz#34a3055babe04ce42467b607d700072c7ff6bf42" + +querystringify@0.0.x: + version "0.0.4" + resolved "https://registry.yarnpkg.com/querystringify/-/querystringify-0.0.4.tgz#0cf7f84f9463ff0ae51c4c4b142d95be37724d9c" + +react-native-fs@2.0.1-rc.2: + version "2.0.1-rc.2" + resolved "https://registry.yarnpkg.com/react-native-fs/-/react-native-fs-2.0.1-rc.2.tgz#e7e00b1d5c48c52c4ca8cd76b4ea62440ae54082" + dependencies: + base-64 "^0.1.0" + utf8 "^2.1.1" + +requires-port@1.0.x: + version "1.0.0" + resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff" + +url-parse@1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.1.1.tgz#d1507970728c9a5f80f471530c57325c3fb0e868" + dependencies: + querystringify "0.0.x" + requires-port "1.0.x" + +utf8@^2.1.1: + version "2.1.2" + resolved "https://registry.yarnpkg.com/utf8/-/utf8-2.1.2.tgz#1fa0d9270e9be850d9b05027f63519bf46457d96"