-
Notifications
You must be signed in to change notification settings - Fork 53
/
Copy pathimageCacheHoc.js
262 lines (205 loc) · 10.4 KB
/
imageCacheHoc.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
/**
*
* This HOC adds the following functionality to react native <Image> components:
*
* - File caching. Images will be downloaded to a cache on the local file system.
* Cache is maintained until cache size meets a certain threshold at which point the oldest
* cached files are purged to make room for fresh files.
*
* - File persistence. Images will be stored indefinitely on local file system.
* Required for images that are related to issues that have been downloaded for offline use.
*
* More info: https://facebook.github.io/react/docs/higher-order-components.html
*
*/
// Load dependencies.
import React from 'react';
import { Platform, ViewPropTypes } from 'react-native';
import PropTypes from 'prop-types';
import FileSystemFactory, { FileSystem } from '../lib/FileSystem';
import traverse from 'traverse';
import validator from 'validator';
import uuid from 'react-native-uuid';
export default function imageCacheHoc(Image, options = {}) {
// Validate options
if (options.validProtocols && !Array.isArray(options.validProtocols)) { throw new Error('validProtocols option must be an array of protocol strings.'); }
if (options.fileHostWhitelist && !Array.isArray(options.fileHostWhitelist)) { throw new Error('fileHostWhitelist option must be an array of host strings.'); }
if (options.cachePruneTriggerLimit && !Number.isInteger(options.cachePruneTriggerLimit) ) { throw new Error('cachePruneTriggerLimit option must be an integer.'); }
if (options.fileDirName && typeof options.fileDirName !== 'string') { throw new Error('fileDirName option must be string'); }
if (options.defaultPlaceholder && (!options.defaultPlaceholder.component || !options.defaultPlaceholder.props)) { throw new Error('defaultPlaceholder option object must include "component" and "props" properties (props can be an empty object)'); }
const defaultCachePruneTriggerLimit = 1024 * 1024 * 15; // Maximum size of image file cache in bytes before pruning occurs. Defaults to 15 MB.
return class extends React.PureComponent {
static propTypes = {
fileHostWhitelist: PropTypes.array,
source: PropTypes.object.isRequired,
permanent: PropTypes.bool,
style: ViewPropTypes.style,
placeholder: PropTypes.shape({
component: PropTypes.func,
props: PropTypes.object
})
};
/**
*
* Manually cache a file.
* Can be used to pre-warm caches.
* If calling this method repeatedly to cache a long list of files,
* be sure to use a queue and limit concurrency so your app performance does not suffer.
*
* @param url {String} - url of file to download.
* @param permanent {Boolean} - whether the file should be saved to the tmp or permanent cache directory.
* @returns {Promise} promise that resolves to an object that contains cached file info.
*/
static async cacheFile(url, permanent = false) {
const cachePruneTriggerLimit = options.cachePruneTriggerLimit || defaultCachePruneTriggerLimit;
const fileDirName = options.fileDirName || null;
const fileSystem = FileSystemFactory(cachePruneTriggerLimit, fileDirName);
const localFilePath = await fileSystem.getLocalFilePathFromUrl(url, permanent);
return {
url: url,
cacheType: (permanent ? 'permanent' : 'cache'),
localFilePath
};
}
/**
*
* Delete all locally stored image files created by react-native-image-cache-hoc (cache AND permanent).
* Calling this method will cause a performance hit on your app until the local files are rebuilt.
*
* @returns {Promise} promise that resolves to an object that contains the flush results.
*/
static async flush() {
const cachePruneTriggerLimit = options.cachePruneTriggerLimit || defaultCachePruneTriggerLimit;
const fileDirName = options.fileDirName || null;
const fileSystem = FileSystemFactory(cachePruneTriggerLimit, fileDirName);
const results = await Promise.all([fileSystem.unlink('permanent'), fileSystem.unlink('cache')]);
return {
permanentDirFlushed: results[0],
cacheDirFlushed: results[1]
};
}
constructor(props) {
super(props);
// Set initial state
this.state = {
localFilePath: null
};
// Assign component unique ID for cache locking.
this.componentId = uuid.v4();
// Track component mount status to avoid calling setState() on unmounted component.
this._isMounted = false;
// Set default options
this.options = {
validProtocols: options.validProtocols || ['https'],
fileHostWhitelist: options.fileHostWhitelist || [],
cachePruneTriggerLimit: options.cachePruneTriggerLimit || defaultCachePruneTriggerLimit,
fileDirName: options.fileDirName || null, // Namespace local file writing to this directory. Defaults to 'react-native-image-cache-hoc'.
defaultPlaceholder: options.defaultPlaceholder || null, // Default placeholder component to render while remote image file is downloading. Can be overridden with placeholder prop. Defaults to <Image> component with style prop passed through.
};
// Init file system lib
this.fileSystem = FileSystemFactory(this.options.cachePruneTriggerLimit, this.options.fileDirName);
// Validate input
this._validateImageComponent();
}
_validateImageComponent() {
// Define validator options
let validatorUrlOptions = { protocols: this.options.validProtocols, require_protocol: true };
if (this.options.fileHostWhitelist.length) {
validatorUrlOptions.host_whitelist = this.options.fileHostWhitelist;
}
// Validate source prop to be a valid web accessible url.
if (
!traverse(this.props).get(['source', 'uri'])
|| !validator.isURL(traverse(this.props).get(['source', 'uri']), validatorUrlOptions)
) {
throw new Error('Invalid source prop. <CacheableImage> props.source.uri should be a web accessible url with a valid protocol and host. NOTE: Default valid protocol is https, default valid hosts are *.');
} else {
return true;
}
}
// Async calls to local FS or network should occur here.
// See: https://reactjs.org/docs/react-component.html#componentdidmount
async componentDidMount() {
// Track component mount status to avoid calling setState() on unmounted component.
this._isMounted = true;
// Set url from source prop
const url = traverse(this.props).get(['source', 'uri']);
// Add a cache lock to file with this name (prevents concurrent <CacheableImage> components from pruning a file with this name from cache).
const fileName = await this.fileSystem.getFileNameFromUrl(url);
FileSystem.lockCacheFile(fileName, this.componentId);
// Init the image cache logic
await this._loadImage(url);
}
/**
*
* Enables caching logic to work if component source prop is updated (that is, the image url changes without mounting a new component).
* See: https://github.com/billmalarky/react-native-image-cache-hoc/pull/15
*
* @param nextProps {Object} - Props that will be passed to component.
*/
async componentWillReceiveProps(nextProps) {
// Set urls from source prop data
const url = traverse(this.props).get(['source', 'uri']);
const nextUrl = traverse(nextProps).get(['source', 'uri']);
// Do nothing if url has not changed.
if (url === nextUrl) return;
// Remove component cache lock on old image file, and add cache lock to new image file.
const fileName = await this.fileSystem.getFileNameFromUrl(url);
const nextFileName = await this.fileSystem.getFileNameFromUrl(nextUrl);
FileSystem.unlockCacheFile(fileName, this.componentId);
FileSystem.lockCacheFile(nextFileName, this.componentId);
// Init the image cache logic
await this._loadImage(nextUrl);
}
/**
*
* Executes the image download/cache logic and calls setState() with to re-render
* component using local file path on completion.
*
* @param url {String} - The remote image url.
* @private
*/
async _loadImage(url) {
// Check local fs for file, fallback to network and write file to disk if local file not found.
const permanent = this.props.permanent ? true : false;
let localFilePath = null;
try {
localFilePath = await this.fileSystem.getLocalFilePathFromUrl(url, permanent);
} catch (error) {
console.warn(error); // eslint-disable-line no-console
}
// Check component is still mounted to avoid calling setState() on components that were quickly
// mounted then unmounted before componentDidMount() finishes.
// See: https://github.com/billmalarky/react-native-image-cache-hoc/issues/6#issuecomment-354490597
if (this._isMounted && localFilePath) {
this.setState({ localFilePath });
}
}
async componentWillUnmount() {
// Track component mount status to avoid calling setState() on unmounted component.
this._isMounted = false;
// Remove component cache lock on associated image file on component teardown.
let fileName = await this.fileSystem.getFileNameFromUrl(traverse(this.props).get(['source', 'uri']));
FileSystem.unlockCacheFile(fileName, this.componentId);
}
render() {
// If media loaded, render full image component, else render placeholder.
if (this.state.localFilePath) {
// Build platform specific file resource uri.
const localFileUri = (Platform.OS == 'ios') ? this.state.localFilePath : 'file://' + this.state.localFilePath; // Android requires the traditional 3 prefixed slashes file:/// in a localhost absolute file uri.
// Extract props proprietary to this HOC before passing props through.
let { permanent, ...filteredProps } = this.props; // eslint-disable-line no-unused-vars
let props = Object.assign({}, filteredProps, { source: { uri: localFileUri } });
return (<Image {...props} />);
} else {
if (this.props.placeholder) {
return (<this.props.placeholder.component {...this.props.placeholder.props} />);
} else if (this.options.defaultPlaceholder) {
return (<this.options.defaultPlaceholder.component {...this.options.defaultPlaceholder.props} />);
} else {
return (<Image style={this.props.style ? this.props.style : undefined} />);
}
}
}
};
}