diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 1537689c..8e71ae6e 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -10,13 +10,14 @@ @@ -24,6 +25,16 @@ + + + + + + + + + data, Callback callback) if (match == null) { callback.reject("no matching route"); + return; } match.delete(path, data, callback); diff --git a/android/app/src/main/java/org/mozilla/magnet/magnetapi/ApiMagnetReact.java b/android/app/src/main/java/org/mozilla/magnet/magnetapi/ApiMagnetReact.java index aec87548..8932cd1e 100644 --- a/android/app/src/main/java/org/mozilla/magnet/magnetapi/ApiMagnetReact.java +++ b/android/app/src/main/java/org/mozilla/magnet/magnetapi/ApiMagnetReact.java @@ -6,7 +6,9 @@ import com.facebook.react.bridge.ReactApplicationContext; import com.facebook.react.bridge.ReactContextBaseJavaModule; import com.facebook.react.bridge.ReactMethod; +import com.facebook.react.bridge.ReadableArray; import com.facebook.react.bridge.ReadableMap; +import com.facebook.react.bridge.ReadableNativeArray; import com.facebook.react.bridge.ReadableNativeMap; import org.json.JSONArray; @@ -15,6 +17,8 @@ import org.mozilla.magnet.api.Utils; import java.util.HashMap; +import java.util.List; +import java.util.Map; public class ApiMagnetReact extends ReactContextBaseJavaModule { private static final String TAG = "APIMagnetReact"; @@ -46,11 +50,39 @@ public void reject(String error) { } @ReactMethod - public void post(String path, ReadableMap data, final Promise promise) { + public void post(String path, ReadableMap map, final Promise promise) { Log.d(TAG, "post"); - HashMap map = ((ReadableNativeMap) data).toHashMap(); + Object data = fromReactArgument(map); + + if (data == null) { + promise.reject("invalid-data-type", "invalid data type"); + return; + } - mApiMagnet.post(path, map, new Api.Callback() { + mApiMagnet.post(path, data, new Api.Callback() { + @Override + public void resolve(Object result) { + promise.resolve(toReactArgument(result)); + } + + @Override + public void reject(String error) { + promise.reject(error, error); + } + }); + } + + @ReactMethod + public void postArray(String path, ReadableArray array, final Promise promise) { + Log.d(TAG, "post"); + Object data = fromReactArgument(array); + + if (data == null) { + promise.reject("invalid-data-type", "invalid data type"); + return; + } + + mApiMagnet.post(path, data, new Api.Callback() { @Override public void resolve(Object result) { promise.resolve(toReactArgument(result)); @@ -86,4 +118,10 @@ static private Object toReactArgument(Object object) { else if (object instanceof JSONObject) return Utils.jsonToWritableMap((JSONObject) object); else return null; } + + static private Object fromReactArgument(Object object) { + if (object instanceof ReadableNativeArray) return ((ReadableNativeArray) object).toArrayList(); + else if (object instanceof ReadableNativeMap) return ((ReadableNativeMap) object).toHashMap(); + else return null; + } } diff --git a/config.js b/config.js index 6b490b06..ce7afa7d 100644 --- a/config.js +++ b/config.js @@ -16,11 +16,6 @@ module.exports = { title: 'Show distance', }, - 'removeOldItems': { - value: false, // default - title: 'Remove old items', - }, - 'enableTelemetry': { value: true, // default title: 'Telemetry', @@ -40,6 +35,7 @@ module.exports = { 'itemExpiring': 10000, // 10 secs 'metadataServiceUrl': 'https://tengam.org/api/v1/metadata', + 'searchServiceUrl': 'https://tengam.org/content/v1/search/url', 'theme': { 'colorBackground': '#f2f2f2', diff --git a/ios/Api.swift b/ios/Api.swift index 9b63783c..b9ba7f2d 100644 --- a/ios/Api.swift +++ b/ios/Api.swift @@ -12,6 +12,7 @@ import SwiftyJSON protocol Api { func get(path: String, callback: ApiCallback) func post(path: String, data: NSDictionary, callback: ApiCallback) + func post(path: String, data: NSArray, callback: ApiCallback) func put(path: String, data: NSDictionary, callback: ApiCallback) func delete(path: String, data: NSDictionary, callback: ApiCallback) func mount(path: String, api: Api) @@ -60,6 +61,17 @@ class ApiBase: Api { api!.post(path, data: data, callback: callback) } + func post(path: String, data: NSArray, callback: ApiCallback) { + let api = find(path) + + guard api != nil else { + callback.onError("Could not find route \(path)") + return + } + + api!.post(path, data: data, callback: callback) + } + func put(path: String, data: NSDictionary, callback: ApiCallback) { let api = find(path) diff --git a/ios/ApiMagnetReact.m b/ios/ApiMagnetReact.m index c7809ac3..d13e9cf8 100644 --- a/ios/ApiMagnetReact.m +++ b/ios/ApiMagnetReact.m @@ -21,6 +21,12 @@ @interface RCT_EXTERN_MODULE(ApiMagnetReact, NSObject); resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) +RCT_EXTERN_METHOD( + postArray:(NSString *)path + data:(NSArray *)data + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject) + RCT_EXTERN_METHOD( put:(NSString *)path data:(NSDictionary *)data diff --git a/ios/ApiMagnetReact.swift b/ios/ApiMagnetReact.swift index 585c5a61..f7ad1f3a 100644 --- a/ios/ApiMagnetReact.swift +++ b/ios/ApiMagnetReact.swift @@ -31,6 +31,16 @@ import Foundation })) } + @objc func postArray(path: String, data: NSArray, resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock) { + api.post(path, data: data, callback: ApiCallback(success: { result in + resolve(result.rawValue) + }, + error: { (error) in + let err = NSError(coder: NSCoder()) + reject("get_error", "Error resolving \(path) with \(data)", err) + })) + } + @objc func put(path: String, data: NSDictionary, resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock) { api.put(path, data: data, callback: ApiCallback(success: { result in resolve(result.rawValue) diff --git a/ios/ApiMetadata.swift b/ios/ApiMetadata.swift index 6593089a..e662674b 100644 --- a/ios/ApiMetadata.swift +++ b/ios/ApiMetadata.swift @@ -35,20 +35,25 @@ class ApiMetadata: ApiBase { // // data["objects"] = elems - override func post(path: String, data: NSDictionary, callback: ApiCallback) { + override func post(path: String, data: NSArray, callback: ApiCallback) { guard System.connectedToNetwork() else { callback.onError("No internet connection") return } let url = NSURL(string: ApiMetadata.SERVER_URL) + var urls = Array>(); - let parameters = ["objects": (data.valueForKey("objects"))!] + for url in data { + urls.append(["url": url as! String]); + } + let body = ["objects": urls] let request = NSMutableURLRequest(URL: url!) + request.HTTPMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.HTTPBody = try! NSJSONSerialization.dataWithJSONObject(parameters, options: []) + request.HTTPBody = try! NSJSONSerialization.dataWithJSONObject(body, options: []) Alamofire.request(request).responseJSON { response in guard response.result.value != nil else { diff --git a/ios/Magnet.xcodeproj/project.pbxproj b/ios/Magnet.xcodeproj/project.pbxproj index 072f1a0b..8a505498 100644 --- a/ios/Magnet.xcodeproj/project.pbxproj +++ b/ios/Magnet.xcodeproj/project.pbxproj @@ -989,6 +989,7 @@ /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/include, "$(SRCROOT)/../node_modules/react-native/React/**", "$(SRCROOT)/../node_modules/react-native-linear-gradient/BVLinearGradient", + "$(SRCROOT)/../node_modules/react-native/Libraries/LinkingIOS", ); INFOPLIST_FILE = Magnet/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 8.0; @@ -1027,6 +1028,7 @@ /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/include, "$(SRCROOT)/../node_modules/react-native/React/**", "$(SRCROOT)/../node_modules/react-native-linear-gradient/BVLinearGradient", + "$(SRCROOT)/../node_modules/react-native/Libraries/LinkingIOS", ); INFOPLIST_FILE = Magnet/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 8.0; @@ -1086,6 +1088,7 @@ /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/include, "$(SRCROOT)/../node_modules/react-native/React/**", "$(SRCROOT)/../node_modules/react-native-linear-gradient/BVLinearGradient", + "$(SRCROOT)/../node_modules/react-native/Libraries/LinkingIOS/RCTLinkingManager.h", ); IPHONEOS_DEPLOYMENT_TARGET = 7.0; MTL_ENABLE_DEBUG_INFO = YES; @@ -1128,6 +1131,7 @@ /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/include, "$(SRCROOT)/../node_modules/react-native/React/**", "$(SRCROOT)/../node_modules/react-native-linear-gradient/BVLinearGradient", + "$(SRCROOT)/../node_modules/react-native/Libraries/LinkingIOS/RCTLinkingManager.h", ); IPHONEOS_DEPLOYMENT_TARGET = 7.0; MTL_ENABLE_DEBUG_INFO = NO; diff --git a/ios/Magnet/AppDelegate.m b/ios/Magnet/AppDelegate.m index 5bb70019..88794bea 100644 --- a/ios/Magnet/AppDelegate.m +++ b/ios/Magnet/AppDelegate.m @@ -11,6 +11,7 @@ #import "RCTBundleURLProvider.h" #import "RCTRootView.h" #import "RCTBridge.h" +#import "RCTLinkingManager.h" // Include the project headers for swift code. (-Swift.h) #import "magnet-Swift.h" @@ -70,4 +71,9 @@ -(void)application:(UIApplication *)application didReceiveLocalNotification:(UIL [self.bridge.eventDispatcher sendDeviceEventWithName:@"notification:applaunch" body:nil]; } +- (BOOL)application:(UIApplication *)application openURL:(NSURL *)url sourceApplication:(NSString *)sourceApplication annotation:(id)annotation { + return [RCTLinkingManager application:application openURL:url sourceApplication:sourceApplication annotation:annotation]; +} + + @end diff --git a/ios/Magnet/Info.plist b/ios/Magnet/Info.plist index df5bbb35..90138c64 100644 --- a/ios/Magnet/Info.plist +++ b/ios/Magnet/Info.plist @@ -18,6 +18,17 @@ 1.2.1 CFBundleSignature ???? + CFBundleURLTypes + + + CFBundleURLName + org.mozilla.magnet + CFBundleURLSchemes + + mozilla-magnet + + + CFBundleVersion 7 LSRequiresIPhoneOS diff --git a/ios/NotificationsHelperIOS10.swift b/ios/NotificationsHelperIOS10.swift index 7d1e1df7..99779142 100644 --- a/ios/NotificationsHelperIOS10.swift +++ b/ios/NotificationsHelperIOS10.swift @@ -48,11 +48,9 @@ class NotificationsHelperIOS10: NSObject, UNUserNotificationCenterDelegate { private func fetchData(url: String, callback: ((JSON) -> Void)) { let api = ApiMetadata() + let urls: NSArray = [url] - let item: Dictionary = ["url": url] - let objects: Dictionary = ["objects": [item]] - - api.post("metadata", data: objects, callback: ApiCallback(success: { json in + api.post("metadata", data: urls, callback: ApiCallback(success: { json in callback(json) }, error: { (err) in debugPrint("Could not get metadata for \(url): \(err)") diff --git a/lib/api/fetch-item.js b/lib/api/fetch-item.js new file mode 100644 index 00000000..c8344a1f --- /dev/null +++ b/lib/api/fetch-item.js @@ -0,0 +1,50 @@ +'use strict'; + +/** + * Dependencies + */ + +const { searchServiceUrl } = require('../../config'); +const debug = require('../debug')('get-item'); +const api = require('.'); + +/** + * Exports + */ + +module.exports = function(url) { + return Promise.all([ + fetchBeaconData(url), + fetchMetadata(url), + ]) + + .then(results => { + var item = results[0][0]; + var metadata = results[1][0]; + + return { + ...item, + metadata, + }; + }); +}; + +function fetchMetadata(url) { + return api.postArray('metadata', [url]); +} + +function fetchBeaconData(url) { + debug('fetch beacon data', url); + const request = new Request(searchServiceUrl, { + method: 'post', + headers: new Headers({ 'Content-Type': 'application/json;charset=utf-8' }), + body: JSON.stringify([url]), + }); + + return fetch(request) + .then(res => res.json()) + .catch(() => { + debug('no beacon data', url); + return []; + }); +} diff --git a/lib/api.js b/lib/api/index.js similarity index 100% rename from lib/api.js rename to lib/api/index.js diff --git a/lib/app.js b/lib/app.js index 8095d3af..ae041311 100644 --- a/lib/app.js +++ b/lib/app.js @@ -4,20 +4,22 @@ * Dependencies */ -const api = require('./api'); const SubscriptionsScene = require('./views/subscriptions-scene'); const SettingsScene = require('./views/settings-scene'); +const ItemScene = require('./views/item/item-scene'); +const ListScene = require('./views/list/list-scene'); +const { HEALTHY } = require('./store/constants'); const notification = require('./notifications'); -const ItemScene = require('./views/item-scene'); -const ListScene = require('./views/list-scene'); +const { bindActionCreators } = require('redux'); const ReactNative = require('react-native'); const { connect } = require('react-redux'); const actions = require('./store/actions'); const track = require('./utils/tracker'); const debug = require('./debug')('App'); -const { flags } = require('../config'); const Scanner = require('./scanner'); const React = require('react'); +const api = require('./api'); +const URL = require('url'); const { BackAndroid, @@ -59,13 +61,13 @@ class App extends React.Component { constructor(props) { super(props); + // pre-bind context + this.onDeepLink = this.onDeepLink.bind(this); + // create the scanner this.scanner = new Scanner({ - onUpdate: this.props.updateItem, - onLost: this.props.removeItem, - onMetadata: this.props.setItemMetadata, - expiryEnabled: this.enabled.bind(this, 'removeOldItems'), - getItems: () => this.props.items, + onFound: this.props.itemFound, + onLost: this.props.itemLost, }); // we need to use telemetry after we @@ -75,9 +77,6 @@ class App extends React.Component { this.setupNotificationListeners(); }); - // when network is bad, the scanner errors - this.scanner.on('networkerror', this.onScannerNetworkError.bind(this)); - // respond to app background/foreground changes AppState.addEventListener('change', this.onAppStateChanged.bind(this)); @@ -86,9 +85,15 @@ class App extends React.Component { componentDidMount() { debug('mounted'); + Linking.getInitialURL().then(url => this.onDeepLink({ url })); + Linking.addEventListener('url', this.onDeepLink); this.startScanning(); } + componentWillUnmount() { + Linking.removeEventListener('url', this.onDeepLink); + } + /** * Triggered whenever the redux store updates. * We use this hook to channel the event to more @@ -97,6 +102,7 @@ class App extends React.Component { componentWillReceiveProps(next) { var current = this.props; if (next.userFlags !== current.userFlags) this.onUserFlagsChange(next.userFlags); + if (next.network !== current.network) this.onNetworkStatusChange(next.network); } render() { @@ -112,25 +118,18 @@ class App extends React.Component { } /** - * Called by `Navigator` to determin which + * Called by `Navigator` to determine which * 'scene` gets rendered. */ - renderScene({ type }, navigator) { - debug('render scene', type, this.props.openItem); + renderScene(route, navigator) { + debug('render scene', route.type); - switch (type) { + switch (route.type) { case 'home': return ; - case 'item': - return ; + case 'item': return ; case 'settings': return 1) { - this.refs.navigator.pop(); + navigator.pop(); return true; } @@ -376,19 +375,16 @@ App.propTypes = { // actions setIndicateScanning: React.PropTypes.func, - setChannels: React.PropTypes.func, - setOpenItem: React.PropTypes.func, - setItemMetadata: React.PropTypes.func, setUserFlag: React.PropTypes.func, - updateItem: React.PropTypes.func, - removeItem: React.PropTypes.func, - clearItems: React.PropTypes.func, + itemFound: React.PropTypes.func, + itemLost: React.PropTypes.func, + itemOpened: React.PropTypes.func, + dispatch: React.PropTypes.func, // state indicateScanning: React.PropTypes.bool, userFlags: React.PropTypes.object, - openItem: React.PropTypes.object, - items: React.PropTypes.array, + items: React.PropTypes.object, }; /** @@ -408,7 +404,7 @@ function mapStateToProps(store) { return { indicateScanning: store.indicateScanning, userFlags: store.userFlags, - openItem: store.openItem, + network: store.network, items: store.items, }; } @@ -420,8 +416,19 @@ function mapStateToProps(store) { * @param {function} dispatch * @return {Object} */ -function mapDispatchToProps() { - return actions; +function mapDispatchToProps(dispatch) { + const bound = bindActionCreators({ + itemFound: actions.itemFound, + itemLost: actions.itemLost, + itemOpened: actions.itemOpened, + setUserFlag: actions.setUserFlag, + setIndicateScanning: actions.setIndicateScanning, + }, dispatch); + + return { + ...bound, + dispatch, + }; } /** diff --git a/lib/scanner/index.js b/lib/scanner/index.js index 86aa43f5..532c5b34 100644 --- a/lib/scanner/index.js +++ b/lib/scanner/index.js @@ -7,18 +7,16 @@ const Emitter = require('events').EventEmitter; const debug = require('../debug')('Scanner'); const ReactNative = require('react-native'); -const fetchMetadata = require('./metadata'); -const track = require('../utils/tracker'); const config = require('../../config'); const { Alert, NativeModules, DeviceEventEmitter, + InteractionManager, } = ReactNative; const MagnetScanner = NativeModules.MagnetScannerReact; -const EXPIRE_CHECK_INTERVAL = 5000; // every 5 secs /** * A JS interface for the Native `magnet-scanner-android` @@ -34,10 +32,7 @@ class Scanner extends Emitter { constructor(options) { super(); this.started = false; - this.expiryEnabled = options.expiryEnabled; - this.onMetadata = options.onMetadata; - this.onUpdate = options.onUpdate; - this.getItems = options.getItems; + this.onFound = options.onFound; this.onLost = options.onLost; this.listener = DeviceEventEmitter @@ -50,53 +45,15 @@ class Scanner extends Emitter { .then(() => { debug('started'); this.injectTestUrls(); - this.startExpireCheck(); }); } stop() { debug('stop'); MagnetScanner.stop(); - this.stopExpireCheck(); return Promise.resolve(); } - startExpireCheck() { - this.nextExpireCheck = setTimeout(() => { - this.checkExpired(); - this.startExpireCheck(); - }, EXPIRE_CHECK_INTERVAL); - } - - stopExpireCheck() { - clearTimeout(this.nextExpireCheck); - } - - checkExpired() { - if (!this.expiryEnabled()) return; - - var now = Date.now(); - this.getItems().forEach(item => { - - // mdns and test urls don't have a distance and - // don't expire in the same way as ble beacons - if (typeof item.distance != 'number') return; - - var age = now - item.timeLastSeen; - - // when an item expires, - // it is removed from the list - if (age > config.itemExpires) { - debug('item expired', item.id); - return this.onLost(item.id); - } - - // when an item is 'expiring' it is greyed out - var expiring = age > config.itemExpiring; - this.onUpdate(item.id, { expiring: expiring }); - }); - } - /** * Show a dialog to warn the user of the * consequences of not having bluetooth @@ -133,67 +90,25 @@ class Scanner extends Emitter { injectTestUrls() { if (!config.flags.injectTestUrls) return; config.testUrls.forEach(url => { - this.onItemFound({ - distance: -1, - url, - }); + this.onItemFound({ url }); }); } /** - * When a URL is found we fetch its - * associated metadata then notify - * the listener. + * Called when an item is found by the scanner. + * Can be called several times per second. * - * @param {String} url - * @private + * We don't fire the callback until all interactions + * (touches/animations) are complete to prevent + * re-rendering and droppping frames. */ onItemFound({ url, distance }) { - debug('item found', url); - const isNew = this.itemIsNew(url); - - this.onUpdate(url, { - id: url, - url, - distance, - timeLastSeen: Date.now(), + InteractionManager.runAfterInteractions(() => { + debug('item found', url); + const id = url; + this.onFound(id, { distance }); }); - - // don't populate if item already found - if (!isNew) return; - - track.itemFound(url); - const start = Date.now(); - - debug('fetching metadata ...'); - fetchMetadata(url) - .then((metadata) => { - if (!metadata) return debug('metadata fetch failed', url); - debug('got metadata: %s', url, metadata); - track.timing('system', 'fetch-metadata', start); - track.itemPopulated(url, metadata.unadaptedUrl); - this.onMetadata(url, metadata); - }) - - .catch(err => { - console.info(err); - if (!isNetworkError(err)) return; - this.emit('networkerror', err); - }); } - - itemIsNew(id) { - return !this.getItems().some(item => item.id === id); - } -} - -/** - * Utils - */ - -function isNetworkError(err) { - return err.name === 'NetworkError' - || err.message === 'Network request failed'; } /** diff --git a/lib/scanner/metadata/clientside.js b/lib/scanner/metadata/clientside.js deleted file mode 100644 index d44c1beb..00000000 --- a/lib/scanner/metadata/clientside.js +++ /dev/null @@ -1,50 +0,0 @@ - -/** - * Dependencies - */ - -var debug = require('../../debug')('Clientside'); -var parser = require('magnet-html-parser'); - -/** - * Exports - */ - -module.exports = function(url) { - debug('fetch and parse', url); - return getContent(url) - .then(({ endUrl, content }) => { - return parser.parse(content, endUrl) - .then(metadata => { - - // copied from magnet-metadata-service - metadata.id - = metadata.url - = metadata.displayUrl - = metadata.unadaptedUrl - = endUrl; - - metadata.originalUrl = url; - return metadata; - }); - }); -}; - -function getContent(url) { - return fetch(url, { - method: 'GET', - headers: { - 'Accept': 'text/html', - 'Content-Type': 'text/html', - }, - }) - - .then(res => { - return res.text().then(text => { - return { - content: text, - endUrl: res.url, - }; - }); - }); -} diff --git a/lib/scanner/metadata/index.js b/lib/scanner/metadata/index.js deleted file mode 100644 index b608783b..00000000 --- a/lib/scanner/metadata/index.js +++ /dev/null @@ -1,46 +0,0 @@ -'use strict'; - -/** - * Dependencies - */ - -var debug = require('../../debug')('metadata'); -var clientside = require('./clientside'); -var serverside = require('./serverside'); - -/** - * Get the metadata for a given URL. - * - * @param {String} url - * @return {Promise} - */ -module.exports = function(url) { - debug('get metadata', url); - return (isInternal(url)) - ? clientside(url) - : serverside(url); -}; - -/** - * A crude test for internal urls. - * - * We should find the local IP of - * the device and test for a matching - * subnet mask. - * - * Worst case scenario is that an external - * URI is fetched clientside. Better that - * than an internal URL failing to be - * fetched serverside. - * - * @param {String} url - * @return {Boolean} - */ -function isInternal(url) { - return /\.local/.test(url) - || isIp(url); -} - -function isIp(string) { - return /^https?:\/\/\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/.test(string); -} diff --git a/lib/scanner/metadata/serverside.js b/lib/scanner/metadata/serverside.js deleted file mode 100644 index 3e00fddb..00000000 --- a/lib/scanner/metadata/serverside.js +++ /dev/null @@ -1,86 +0,0 @@ - -/** - * Dependencies - */ - -var { metadataServiceUrl } = require('../../../config'); -var debug = require('../../debug')('Metadata'); - -function Metadata() { - this.batch = []; -} - -Metadata.prototype = { - get(url) { - debug('get'); - - var index = this.batch.length; - this.batch.push({ url: url }); - - return this.schedule(url) - .then(responses => { - var response = responses[index]; - debug('response', response); - if (response.error) return Promise.reject(error(response.error)); - return response; - }); - }, - - schedule() { - if (this.pending) return this.pending.promise; - this.pending = new Deferred(); - - setTimeout(() => { - var promise = this.pending; - - request(this.batch) - .then(json => promise.resolve(json)) - .catch(err => promise.reject(err)); - - delete this.pending; - this.batch = []; - }, 200); - - return this.pending.promise; - }, -}; - -/** - * Exports - */ - -var metadata = new Metadata(); -module.exports = function(url) { - return metadata.get(url); -}; - -/** - * Utils - */ - -function request(urls) { - debug('request', metadataServiceUrl, urls); - - var request = new Request(metadataServiceUrl, { - method: 'post', - headers: new Headers({ - 'Content-Type': 'application/json;charset=utf-8', - }), - - body: JSON.stringify({ objects: urls }), - }); - - return fetch(request) - .then(res => res.json()); -} - -function error(message) { - return new Error(message); -} - -function Deferred() { - this.promise = new Promise(function(resolve, reject) { - this.resolve = resolve; - this.reject = reject; - }.bind(this)); -} diff --git a/lib/store/actions.js b/lib/store/actions.js index 5cf86aab..35dbe323 100644 --- a/lib/store/actions.js +++ b/lib/store/actions.js @@ -5,36 +5,113 @@ */ const { bindActionCreators } = require('redux'); -const store = require('./'); +const fetchItem = require('../api/fetch-item'); +const { isNetworkError } = require('../utils'); +const debug = require('../debug')('actions'); +const track = require('../utils/tracker'); +const { dispatch } = require('.'); +const api = require('../api'); + +const { + INCOMPLETE, + COMPLETE, + ERROR, + HEALTHY, +} = require('./constants'); /** * Exports */ const actions = { - updateItem(id, update) { + itemFound(id, value) { + return (dispatch, getState) => { + const exists = getState().itemsNearby.indexOf(id) > -1; + if (!exists) dispatch(actions.newItemFound(id, value)); + else dispatch(actions.itemUpdated(id, value)); + }; + }, + + newItemFound(id, value) { + track.itemFound(id); + return { + type: 'ITEM_FOUND', + id, + value, + }; + }, + + itemUpdated(id, value) { + return { + type: 'ITEM_UPDATED', + id, + value, + }; + }, + + itemLost(id) { return { - type: 'UPDATE_ITEM', + type: 'ITEM_LOST', id, - update, }; }, - setItemMetadata(id, metadata) { + itemOpened(id) { return { - type: 'SET_ITEM_METADATA', - metadata, + type: 'ITEM_OPENED', id, }; }, - setOpenItem(id) { + itemClosed(id) { return { - type: 'SET_OPEN_ITEM', + type: 'ITEM_CLOSED', id, }; }, + fetchItemIfNeeded(id) { + return (dispatch, getState) => { + if (!id) return Promise.resolve(); + if (!shouldFetchItem(getState(), id)) return Promise.resolve(); + + dispatch(actions.itemFetching(id)); + + return fetchItem(id) + .then(item => { + dispatch(actions.networkStatusUpdate(HEALTHY)); + dispatch(actions.itemFetched(id, item)); + }) + + .catch(e => { + dispatch(actions.itemFetchErrored(id, e)); + if (isNetworkError(e)) dispatch(actions.networkStatusUpdate(ERROR)); + }); + }; + }, + + itemFetching(id) { + return { + type: 'ITEM_FETCHING', + id, + }; + }, + + itemFetched(id, value) { + return { + type: 'ITEM_FETCHED', + id, + value, + }; + }, + + itemFetchErrored(id, value) { + return { + type: 'ITEM_FETCH_ERRORED', + value, + }; + }, + removeItem(id) { return { type: 'REMOVE_ITEM', @@ -42,9 +119,9 @@ const actions = { }; }, - clearItems() { + refreshItems() { return { - type: 'CLEAR_ITEMS', + type: 'REFRESH_ITEMS', }; }, @@ -63,38 +140,74 @@ const actions = { }; }, + fetchChannelsIfNeeded() { + return (dispatch, getState) => { + if (!shouldFetchChannels(getState())) return Promise.resolve(); + + // update local state + dispatch(actions.channelsFetching()); + debug('fetching channels ...'); + + // fetch remote data + return api.get('channels') + .then(result => dispatch(actions.channelsFetched(result))) + .catch(err => dispatch(actions.channelsFetchErrored(err))); + }; + }, + channelsFetching() { return { type: 'CHANNELS_FETCHING', }; }, - channelsFetched(responseType, value) { + channelsFetched(value) { return { type: 'CHANNELS_FETCHED', - responseType, value, }; }, - setChannels(state, value) { + channelsFetchErrored(value) { return { - type: 'SET_CHANNELS', - state, + type: 'CHANNELS_FETCH_ERRORED', value, }; }, + fetchSubscriptionsIfNeeded() { + return (dispatch, getState) => { + if (!shouldFetchSubscriptions(getState())) return Promise.resolve(); + + // update local state + dispatch(actions.subscriptionsFetching()); + debug('fetching subscriptions ...'); + + // fetch remote data + return api.get('subscriptions') + .then(result => dispatch(actions.subscriptionsFetched(result))) + .catch(err => dispatch(actions.subscriptionsFetchErrored(err))); + }; + }, + + // TODO: Migrate to async action like `items` subscriptionsFetching() { return { type: 'SUBSCRIPTIONS_FETCHING', }; }, - subscriptionsFetched(responseType, value) { + // TODO: Migrate to async action like `items` + subscriptionsFetched(value) { return { type: 'SUBSCRIPTIONS_FETCHED', - responseType, + value, + }; + }, + + subscriptionsFetchErrored(value) { + return { + type: 'SUBSCRIPTIONS_FETCH_ERRORED', value, }; }, @@ -119,7 +232,53 @@ const actions = { subscription, }; }, + + networkStatusUpdate(value) { + return { + type: 'NETWORK_STATUS_UPDATE', + value: { + status: value, + timestamp: Date.now(), + }, + }; + }, }; -module.exports = bindActionCreators(actions, store.dispatch); -module.exports.actions = actions; +function shouldFetchItem(state, id) { + const item = state.items[id]; + if (!item) return true; + + switch (item.status) { + case COMPLETE: + case INCOMPLETE: + case ERROR: + return false; + default: + return true; + } +} + +function shouldFetchChannels({ channels }) { + switch (channels.status) { + case COMPLETE: + case INCOMPLETE: + return false; + case ERROR: + default: + return true; + } +} + +function shouldFetchSubscriptions({ subscriptions }) { + switch (subscriptions.status) { + case COMPLETE: + case INCOMPLETE: + return false; + case ERROR: + default: + return true; + } +} + +module.exports = actions; +module.exports.bound = bindActionCreators(actions, dispatch); diff --git a/lib/store/constants.js b/lib/store/constants.js new file mode 100644 index 00000000..025b01f7 --- /dev/null +++ b/lib/store/constants.js @@ -0,0 +1,7 @@ + +module.exports = { + INCOMPLETE: 'incomplete', + COMPLETE: 'complete', + ERROR: 'error', + HEALTHY: 'healthy', +}; diff --git a/lib/store/index.js b/lib/store/index.js index dc7b77a2..1f503dd9 100644 --- a/lib/store/index.js +++ b/lib/store/index.js @@ -4,11 +4,15 @@ * Dependencies */ -const { createStore } = require('redux'); -const reducer = require('./reducer'); +import { createStore, applyMiddleware } from 'redux'; +import reducer from './reducer'; +import thunk from 'redux-thunk'; /** * Exports */ -module.exports = createStore(reducer); +module.exports = createStore( + reducer, + applyMiddleware(thunk) +); diff --git a/lib/store/reducer.js b/lib/store/reducer.js index 5f534941..8b4d22f6 100644 --- a/lib/store/reducer.js +++ b/lib/store/reducer.js @@ -7,14 +7,32 @@ const debug = require('../debug')('reducer'); const config = require('../../config'); +const { + INCOMPLETE, + COMPLETE, + ERROR, + HEALTHY, +} = require('./constants'); + const initialState = { - items: [], + items: {}, + itemsNearby: [], openItem: null, + network: { + status: HEALTHY, + timestamp: Date.now(), + }, + indicateScanning: undefined, userFlags: config.userFlags, - channels: {}, + + channels: { + status: null, + value: null, + }, + subscriptions: { - state: null, + status: null, value: null, }, }; @@ -24,18 +42,28 @@ module.exports = (state, action) => { debug('action', action.type); switch (action.type) { - case 'UPDATE_ITEM': return updateItem(state, action.id, action.update); - case 'REMOVE_ITEM': return removeItem(state, action.id); - case 'SET_ITEM_METADATA': return setItemMetadata(state, action.id, action.metadata); + case 'ITEM_FOUND': return itemFound(state, action.id, action.value); + case 'ITEM_UPDATED': return itemUpdated(state, action.id, action.value); + case 'ITEM_LOST': return itemLost(state, action.id); + case 'ITEM_OPENED': return itemOpened(state, action.id); + case 'ITEM_CLOSED': return itemClosed(state, action.id); + case 'ITEM_FETCHING': return itemFetching(state, action.id); + case 'ITEM_FETCHED': return itemFetched(state, action.id, action.value); + case 'ITEM_FETCH_ERROR': return itemFetchError(state, action.id, action.value); + case 'REFRESH_ITEMS': return refreshItems(state); + case 'SET_USER_FLAG': return setUserFlag(state, action.key, action.value); - case 'CLEAR_ITEMS': return { ...state, items: [] }; - case 'SET_OPEN_ITEM': return setOpenItem(state, action.id); - case 'CLOSE_ITEM': return { ...state, openItem: null }; case 'SET_INDICATE_SCANNING': return { ...state, indicateScanning: action.value }; + case 'NETWORK_STATUS_UPDATE': return networkStatusUpdate(state, action.value); + case 'CHANNELS_FETCHING': return channelsFetching(state); - case 'CHANNELS_FETCHED': return channelsFetched(state, action); + case 'CHANNELS_FETCHED': return channelsFetched(state, action.value); + case 'CHANNELS_FETCH_ERRORED': return channelsFetchErrored(state, action.value); + case 'SUBSCRIPTIONS_FETCHING': return subscriptionsFetching(state); - case 'SUBSCRIPTIONS_FETCHED': return subscriptionsFetched(state, action); + case 'SUBSCRIPTIONS_FETCHED': return subscriptionsFetched(state, action.value); + case 'SUBSCRIPTIONS_FETCH_ERRORED': return subscriptionsFetchErrored(state, action.value); + case 'SUBSCRIBED': return subscribed(state, action); case 'UNSUBSCRIBED': return unsubscribed(state, action); case 'SUBSCRIPTION_UPDATED': return subscriptionUpdated(state, action); @@ -43,105 +71,142 @@ module.exports = (state, action) => { } }; -function updateItem(state, id, update) { - var existing = findItem(state.items, id); - if (!existing) return addItem(state, update); - debug('update item', id); +function itemFetching(state, id) { + debug('item fetching', id); + var item = findItem(state.items, id) || createItem(id, {}); - var updated = { - ...existing.item, - ...update, + return { + ...state, + items: { + ...state.items, + [id]: { + ...item, + status: INCOMPLETE, + }, + }, }; +} - setDistance(updated, update.distance); +function itemFetched(state, id, value) { + var item = findItem(state.items, id); + if (!item) return state; + debug('item fetched', id, item, value); - // don't mutate if objects are equal (shallow) - if (equal(existing.item, updated)) { - debug('item didnt change', id); - return state; - } + return { + ...state, + items: { + ...state.items, + [id]: { + ...item, + status: COMPLETE, + value: { + ...item.value, + ...value, + }, + }, + }, + }; +} + +function itemFetchError(state, id, value) { + debug('item fetch error', id, value); + var item = findItem(state.items, id); + if (!item) return state; return { ...state, - items: [ - ...state.items.slice(0, existing.index), - updated, - ...state.items.slice(existing.index + 1), - ], + items: { + ...state.items, + [id]: { + status: ERROR, + value: { + ...item.value, + error: value, + }, + }, + }, }; } -function setDistance(item, newDistance) { - if (typeof newDistance != 'number' || newDistance === -1) return; - var distances = item.distances; - distances.push(newDistance); - var length = distances.length; - if (length > 5) distances.shift(); - item.regulatedDistance = roundDistance(getAverage(distances)); - debug('regulated distance', distances, item.regulatedDistance); +function itemFound(state, id, { distance }) { + debug('item found', id); + const items = state.items; + const item = items[id] || createItem(id, { distance }); + + return { + ...state, + items: { + ...items, + [id]: { ...item }, + }, + + itemsNearby: [ + ...state.itemsNearby, + id, + ], + }; } -function setItemMetadata(state, id, metadata) { - var existing = findItem(state.items, id); - if (!existing) return state; +function itemUpdated(state, id, { distance }) { + debug('item found', id); + const previous = findItem(state.items, id); + const items = state.items; + + if (!previous) return state; - var updated = { - ...existing.item, - metadata: metadata, + const next = { + ...previous, + value: { + ...previous.value, + ...normalizeDistance(distance, previous.value), + }, }; return { ...state, - items: [ - ...state.items.slice(0, existing.index), - updated, - ...state.items.slice(existing.index + 1), - ], + items: { + ...items, + [id]: next, + }, }; } -function addItem(state, item) { - debug('add item', item.id); - var copy = { - ...item, - id: item.id, - url: item.url, - expiring: false, - distance: null, - distances: [], - regulatedDistance: null, - }; +function itemLost(state, id) { + debug('item lost', id); + const { itemsNearby } = state; - setDistance(copy, item.distance); + var index = itemsNearby.indexOf(id); + if (!~index) return state; return { ...state, - items: [ - copy, - ...state.items, + itemsNearby: [ + ...itemsNearby.slice(0, index), + ...itemsNearby.slice(index + 1), ], }; } -function removeItem(state, id) { - debug('remove item', id); - var existing = findItem(state.items, id); - if (!existing) return state; +function itemOpened(state, id) { + debug('item opened', id); + return { + ...state, + openItem: id, + }; +} +function itemClosed(state) { + debug('item closed'); return { ...state, - items: [ - ...state.items.slice(0, existing.index), - ...state.items.slice(existing.index + 1), - ], + openItem: null, }; } -function setOpenItem(state, id) { - var found = findItem(state.items, id); +function refreshItems(state) { return { ...state, - openItem: found && found.item, + itemsNearby: [], }; } @@ -168,22 +233,38 @@ function setUserFlag(state, key, value) { } function channelsFetching(state) { + debug('channels fetching'); + return { ...state, channels: { ...state.channels, - isFetching: true, + status: INCOMPLETE, }, }; } -function channelsFetched(state, { responseType, value }) { +function channelsFetched(state, value) { + debug('channels fetched'); + return { ...state, channels: { ...state.channels, - isFetching: false, - responseType, + status: COMPLETE, + value, + }, + }; +} + +function channelsFetchErrored(state, value) { + debug('channels fetch errored'); + + return { + ...state, + channels: { + ...state.channels, + status: ERROR, value, }, }; @@ -196,20 +277,32 @@ function subscriptionsFetching(state) { ...state, subscriptions: { ...state.subscriptions, - isFetching: true, + status: INCOMPLETE, }, }; } -function subscriptionsFetched(state, { responseType, value }) { +function subscriptionsFetched(state, value) { debug('subscriptions fetched'); return { ...state, subscriptions: { ...state.subscriptions, - isFetching: false, - responseType, + status: COMPLETE, + value, + }, + }; +} + +function subscriptionsFetchErrored(state, value) { + debug('subscriptions fetch errored'); + + return { + ...state, + subscriptions: { + ...state.subscriptions, + status: ERROR, value, }, }; @@ -269,17 +362,60 @@ function subscriptionUpdated(state, { subscription }) { }; } +function networkStatusUpdate(state, { status, timestamp }) { + return { + ...state, + network: { + status, + timestamp, + }, + }; +} + +/** + * Utils + */ + function findItem(items, id) { debug('find item', id); - if (!id) return null; + return items[id]; +} + +function createItem(id, { distance }) { + return { + id, + status: null, + value: { + url: id, + ...normalizeDistance(distance, {}), + }, + }; +} + +function normalizeDistance(newDistance, { _distanceHistory=[] }) { + if (typeof newDistance != 'number' || newDistance === -1) { + return { + distance: null, + _distanceHistory: [], + }; + } + + // copy + _distanceHistory = _distanceHistory.slice(); + _distanceHistory.push(newDistance); + + // enforce max-length + var length = _distanceHistory.length; + if (length > 5) _distanceHistory.shift(); - var index; - var item = items.find((item, i) => { - index = i; - return item.id === id; - }); + // calculate normalized distance + const distance = roundDistance(getAverage(_distanceHistory)); + debug('regulated distance', _distanceHistory, distance); - return item ? { item, index } : null; + return { + distance, + _distanceHistory, + }; } function roundDistance(value) { @@ -288,15 +424,7 @@ function roundDistance(value) { } function getAverage(array) { + if (array.length === 1) return array[0]; var sum = array.reduce((sum, distance) => sum + distance, 0); return sum / array.length; } - -function equal(a, b) { - for (var key in b) { - if (!b.hasOwnProperty(key)) continue; - if (a[key] !== b[key]) return false; - } - - return true; -} diff --git a/lib/utils/index.js b/lib/utils/index.js new file mode 100644 index 00000000..48ebf4ba --- /dev/null +++ b/lib/utils/index.js @@ -0,0 +1,6 @@ + +export function isNetworkError(err) { + return err.name === 'NetworkError' + || err.message === 'Network request failed' + || err.code.indexOf('NoConnectionError') > -1; +} diff --git a/lib/views/item-scene.js b/lib/views/item/item-detail.js similarity index 74% rename from lib/views/item-scene.js rename to lib/views/item/item-detail.js index 9458c166..46f039bd 100644 --- a/lib/views/item-scene.js +++ b/lib/views/item/item-detail.js @@ -1,43 +1,72 @@ +'use strict'; + /** * Dependencies */ -const debug = require('../debug')('ItemScene'); -const theme = require('../../config').theme; +const debug = require('../../debug')('ItemDetail'); +const theme = require('../../../config').theme; const ReactNative = require('react-native'); const tinycolor = require('tinycolor2'); const React = require('react'); const { + ActivityIndicator, TouchableOpacity, StyleSheet, - View, ScrollView, - Text, - Image, Linking, + Image, + View, + Text, } = ReactNative; -class ItemScene extends React.Component { +const { + INCOMPLETE, + ERROR, +} = require('../../store/constants'); + +class ItemDetail extends React.Component { constructor(props) { super(props); + this.state = { animating: true }; debug('created'); } - render() { - const metadata = this.props.item.metadata || {}; - console.log('render', this.props.item); + shouldComponentUpdate(nextProps) { + if (this.dragging) return false; + return nextProps.item !== this.props.item; + } + render() { return ( - - {this.renderMedia(metadata)} - {this.renderUrlBar(metadata)} - {this.renderText(metadata)} + + {this.renderContent(this.props.item)} {this.renderHeader()} - + ); } + renderContent(item) { + if (!item || item.status === INCOMPLETE) return this.renderLoading(); + if (item.status === ERROR) return this.goBack(); + const { metadata } = item.value; + return + {this.renderMedia(metadata)} + {this.renderUrlBar(metadata)} + {this.renderText(metadata)} + ; + } + + renderLoading() { + return + + ; + } + renderMedia({ image, title, themeColor }) { debug('render media', image, title, themeColor); if (image) return this.renderMediaImage(image); @@ -104,7 +133,7 @@ class ItemScene extends React.Component { + source={require('../../images/item-scene-room.png')}/> {label} ); @@ -119,27 +148,31 @@ class ItemScene extends React.Component { onPress={this.onClosePress.bind(this)}> + source={require('../../images/item-scene-close.png')}/> ); } onClosePress() { + this.goBack(); + } + + goBack() { this.props.navigator.pop(); } } -ItemScene.propTypes = { +ItemDetail.propTypes = { navigator: React.PropTypes.object.isRequired, - item: React.PropTypes.object.isRequired, + item: React.PropTypes.object, }; /** * Exports */ -module.exports = ItemScene; +module.exports = ItemDetail; const VERTICAL_MARGIN = 20; @@ -165,6 +198,10 @@ const styles = StyleSheet.create({ height: 20, }, + scroller: { + flex: 1, + }, + mediaImage: { height: 220, }, @@ -240,4 +277,9 @@ const styles = StyleSheet.create({ height: 20, marginRight: 5, }, + + loading: { + flex: 1, + justifyContent: 'center', + }, }); diff --git a/lib/views/item/item-scene.js b/lib/views/item/item-scene.js new file mode 100644 index 00000000..6bd7162e --- /dev/null +++ b/lib/views/item/item-scene.js @@ -0,0 +1,69 @@ +'use strict'; + +/** + * Dependencies + */ + +import { fetchItemIfNeeded } from '../../store/actions'; +import React, { PropTypes, Component } from 'react'; +import { connect } from 'react-redux'; +import ItemDetail from './item-detail'; + +class ItemScene extends Component { + componentDidMount() { + const { openItem, dispatch } = this.props; + dispatch(fetchItemIfNeeded(openItem)); + } + + componentWillReceiveProps(nextProps) { + if (nextProps.openItem !== this.props.openItem) { + const { dispatch, openItem } = nextProps; + dispatch(fetchItemIfNeeded(openItem)); + } + } + + render() { + const { openItem, items, navigator } = this.props; + const item = items[openItem]; + + return ; + } +} + +ItemScene.propTypes = { + navigator: PropTypes.object.isRequired, + itemUrl: PropTypes.string, + openItem: PropTypes.string, + items: PropTypes.object, + setOpenItem: PropTypes.func, + dispatch: PropTypes.func.isRequired, +}; + +/** + * Takes the redux `store` (passed down by + * the parent `Provider`) view and maps + * specific properties onto the App's + * `this.props` object. + * + * This means the app never touches the + * redux store directly and prevents + * hacky code being written. + * + * @param {ReduxStore} store + * @return {Object} + */ +function mapStateToProps({ openItem, items }) { + return { + items, + openItem, + }; +} + +/** + * Exports + */ + +module.exports = connect(mapStateToProps)(ItemScene); diff --git a/lib/views/list-scene.js b/lib/views/list-scene.js deleted file mode 100644 index d9a89505..00000000 --- a/lib/views/list-scene.js +++ /dev/null @@ -1,142 +0,0 @@ - -/** - * Dependencies - */ - -const debug = require('../debug')('ListScene'); -const ReactNative = require('react-native'); -const HeaderBar = require('./header-bar'); -const ListView = require('./list'); -const React = require('react'); - -const { - TouchableOpacity, - StyleSheet, - Image, - View, -} = ReactNative; - -class ListScene extends React.Component { - constructor(props) { - super(props); - } - - render() { - debug('render', this.props.scanning); - var { sortByDistance, showDistance } = this.props.userFlags; - - return ( - - {this.renderHeader()} - - - ); - } - - renderHeader() { - return - - } - - right={[ - this.renderSubscriptionsButton(), - - - , - ]} - />; - } - - renderSubscriptionsButton() { - return - - ; - } - - onRefresh() {} - - onSubscriptionsPress() { - debug('on subscriptions press'); - this.props.navigator.push({ type: 'subscriptions' }); - } - - onSettingsPress() { - debug('on settings press'); - this.props.navigator.push({ type: 'settings' }); - } -} - -ListScene.propTypes = { - items: React.PropTypes.array, - scanning: React.PropTypes.bool, - navigator: React.PropTypes.object, - userFlags: React.PropTypes.object, - onItemPress: React.PropTypes.func.isRequired, - onItemSwiped: React.PropTypes.func.isRequired, - onRefresh: React.PropTypes.func.isRequired, -}; - -/** - * Exports - */ - -module.exports = ListScene; - -const styles = StyleSheet.create({ - root: { - flex: 1, - backgroundColor: '#fff', - }, - - logo: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - paddingTop: 1, - }, - - logoImage: { - width: 141.76, - height: 22.8, - }, - - settings: { - width: 37, - paddingTop: 1, - paddingLeft: 2, - justifyContent: 'center', - alignItems: 'center', - }, - - settingImage: { - width: 22, - height: 22, - }, - - subscriptionsImage: { - width: 22, - height: 23, - }, -}); diff --git a/lib/views/list-item.js b/lib/views/list/list-item.js similarity index 93% rename from lib/views/list-item.js rename to lib/views/list/list-item.js index 997a7d90..7c6de9fd 100644 --- a/lib/views/list-item.js +++ b/lib/views/list/list-item.js @@ -5,8 +5,8 @@ */ const LinearGradient = require('react-native-linear-gradient').default; -const debug = require('../debug')('ListItem'); -const theme = require('../../config').theme; +const debug = require('../../debug')('ListItem'); +const theme = require('../../../config').theme; const tinycolor = require('tinycolor2'); const React = require('react'); @@ -68,12 +68,13 @@ class ListItem extends React.Component { shouldComponentUpdate(nextProps) { if (this.dragging) return false; return nextProps.item !== this.props.item - || nextProps.showDistance !== this.props.showDistance; + || nextProps.showDistance !== this.props.showDistance + || nextProps.item.distance !== this.props.item.distance; } render() { - debug('render'); - var { item } = this.props; + var item = this.props.item.value; + debug('render', item); var opacity = this.state.opacity; var pan = this.state.pan; var translateX = pan.x; @@ -104,14 +105,10 @@ class ListItem extends React.Component { ? ['rgba(0,0,0,0)', 'rgba(0,0,0,0.5)'] : ['transparent', 'transparent']; - var style = [styles.content, { - opacity: item.expiring ? 0.5 : 1, - }]; - return ( {this.renderBackground(item)} {this.renderDistance(item)} @@ -131,16 +128,15 @@ class ListItem extends React.Component { ); } - renderDistance({ regulatedDistance, expiring }) { - debug('render distance', regulatedDistance); - var ignore = !regulatedDistance + renderDistance({ distance }) { + debug('render distance', distance); + var ignore = !distance || !this.props.showDistance - || regulatedDistance === Infinity - || regulatedDistance === -1 - || expiring; + || distance === Infinity + || distance === -1; if (ignore) return; - var text = `${regulatedDistance}m`; + var text = `${distance}m`; return dispatch(fetchItemIfNeeded(item.id))); + } + + render() { + debug('render'); + + return ( + + {this.renderHeader()} + + + ); + } + + renderHeader() { + return + + } + + right={[ + this.renderSubscriptionsButton(), + + + , + ]} + />; + } + + renderSubscriptionsButton() { + return + + ; + } + + onRefresh() {} + + /** + * Called when a list-item is pressed. + * + * Depending on what prefs are set, this + * could open the expanded view, or simply + * navigate to the item's url. + */ + onItemPress(id) { + track.tapListItem(id); + if (!flags.itemsExpandable) return Linking.openURL(id); + this.props.itemOpened(id); + this.props.navigator.push({ type: 'item' }); + } + + onSubscriptionsPress() { + debug('on subscriptions press'); + this.props.navigator.push({ type: 'subscriptions' }); + } + + onSettingsPress() { + debug('on settings press'); + this.props.navigator.push({ type: 'settings' }); + } +} + +ListScene.propTypes = { + indicateScanning: PropTypes.bool, + navigator: PropTypes.object, + userFlags: PropTypes.object, + showDistance: PropTypes.bool, + sortByDistance: PropTypes.bool, + items: PropTypes.array, + + onRefresh: PropTypes.func.isRequired, + itemOpened: PropTypes.func.isRequired, + dispatch: PropTypes.func.isRequired, +}; + +/** + * Takes the redux `store` (passed down by + * the parent `Provider`) view and maps + * specific properties onto the App's + * `this.props` object. + * + * This means the app never touches the + * redux store directly and prevents + * hacky code being written. + * + * @param {ReduxStore} store + * @return {Object} + */ +function mapStateToProps({ items, itemsNearby, userFlags, indicateScanning }) { + const { sortByDistance, showDistance } = userFlags; + + return { + items: itemsNearby.map(id => items[id]), + sortByDistance: sortByDistance.value, + showDistance: showDistance.value, + userFlags, + indicateScanning, + }; +} +/** + * Maps the methods exported from `action-creators.js` + * to `this.props.`. + * + * @param {function} dispatch + * @return {Object} + */ +function mapDispatchToProps(dispatch) { + const { itemOpened } = actions; + + return { + ...bindActionCreators({ itemOpened }, dispatch), + dispatch, + }; +} + +/** + * Exports + */ + +module.exports = connect(mapStateToProps, mapDispatchToProps)(ListScene); + +const styles = StyleSheet.create({ + root: { + flex: 1, + backgroundColor: '#fff', + }, + + logo: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + paddingTop: 1, + }, + + logoImage: { + width: 141.76, + height: 22.8, + }, + + settings: { + width: 37, + paddingTop: 1, + paddingLeft: 2, + justifyContent: 'center', + alignItems: 'center', + }, + + settingImage: { + width: 22, + height: 22, + }, + + subscriptionsImage: { + width: 22, + height: 23, + }, +}); diff --git a/lib/views/list.js b/lib/views/list/list.js similarity index 90% rename from lib/views/list.js rename to lib/views/list/list.js index ca9e4257..e1837464 100644 --- a/lib/views/list.js +++ b/lib/views/list/list.js @@ -4,23 +4,25 @@ * Dependencies */ -const debug = require('../debug')('ListView'); -const ListItem = require('./list-item'); -const React = require('react'); +import ListItem from './list-item'; +import Debug from '../../debug'; +import React from 'react'; -const { +import { StyleSheet, ScrollView, View, Text, RefreshControl, LayoutAnimation, -} = require('react-native'); +} from 'react-native'; -const { +import { theme, flags, -} = require('../../config'); +} from '../../../config'; + +const debug = Debug('ListView'); class ListView extends React.Component { constructor(props) { @@ -89,14 +91,14 @@ class ListView extends React.Component { renderItems() { debug('render list items'); - var items = this.props.items.filter(item => !item.hidden); + var { items} = this.props; // only sort if preffed on if (this.props.sortByDistance) { items = items.sort(this.sort); } - return items.map((item) => { + return items.map(item => { debug('render id', item.id); return ; }); } @@ -126,8 +128,11 @@ class ListView extends React.Component { } sort(item1, item2) { - var distance1 = item1.regulatedDistance; - var distance2 = item2.regulatedDistance; + item1 = item1.value; + item2 = item2.value; + + var distance1 = item1.distance; + var distance2 = item2.distance; if (distance1 === distance2) return 0; @@ -161,11 +166,6 @@ class ListView extends React.Component { this.props.onRefresh(); } - onItemPress({props}) { - debug('on item press'); - this.props.onItemPress(props.item); - } - onScroll({nativeEvent:{contentOffset}}) { this.scrollY = contentOffset.y; } diff --git a/lib/views/subscriptions-scene.js b/lib/views/subscriptions-scene.js index 6bd57225..d98f3bce 100644 --- a/lib/views/subscriptions-scene.js +++ b/lib/views/subscriptions-scene.js @@ -4,16 +4,24 @@ * Dependencies */ -const debug = require('../debug')('SubscriptionsScene', 1); +const debug = require('../debug')('SubscriptionsScene'); +const { COMPLETE } = require('../store/constants'); const SubscribeButton = require('./subscribe-button'); const ReactNative = require('react-native'); const { connect } = require('react-redux'); const HeaderBar = require('./header-bar'); -const actions = require('../store/actions'); const { theme } = require('../../config'); const React = require('react'); const api = require('../api'); +const { + subscribed, + unsubscribed, + subscriptionUpdated, + fetchChannelsIfNeeded, + fetchSubscriptionsIfNeeded, +} = require('../store/actions'); + const { TouchableOpacity, InteractionManager, @@ -26,9 +34,6 @@ const { } = ReactNative; class SubscriptionsScene extends React.Component { - constructor(props) { - super(props); - } /** * Fetches the data for the view after @@ -40,8 +45,9 @@ class SubscriptionsScene extends React.Component { */ componentDidMount() { InteractionManager.runAfterInteractions(() => { - this.updateChannels(); - this.updateSubscriptions(); + const { dispatch } = this.props; + dispatch(fetchChannelsIfNeeded()); + dispatch(fetchSubscriptionsIfNeeded()); }); } @@ -119,36 +125,9 @@ class SubscriptionsScene extends React.Component { return subscriptions.value && !!subscriptions.value[channelId]; } - updateChannels() { - if (this.props.channels.isFetching) return; - if (this.props.channels.value) return; - - debug('fetching channels ...', this.props.channels); - - // update local state - this.props.channelsFetching(); - - // fetch remote data - api.get('channels') - .then(result => this.props.channelsFetched('success', result)) - .catch(err => this.props.channelsFetched('error', err)); - } - - updateSubscriptions() { - if (this.props.subscriptions.state) return; - debug('update subscriptions', this.props.subscriptions); - - // update local state - this.props.subscriptionsFetching(); - - // fetch remote data - api.get('subscriptions') - .then(result => this.props.subscriptionsFetched('success', result)) - .catch(err => this.props.subscriptionsFetched('error', err)); - } - dataReady() { - return !!this.props.channels.value; + return this.props.channels.status === COMPLETE + && this.props.subscriptions.status === COMPLETE; } onSubscriptionChange(channel_id, value) { @@ -158,9 +137,10 @@ class SubscriptionsScene extends React.Component { onSubscribed(channel_id) { debug('on unsubscribed', channel_id); + const { dispatch } = this.props; // update local state - this.props.subscribed(channel_id); + dispatch(subscribed(channel_id)); // persist it api.post('subscriptions', { @@ -170,12 +150,13 @@ class SubscriptionsScene extends React.Component { // update local state with latest model .then(subscription => { - this.props.subscriptionUpdated(subscription); + dispatch(subscriptionUpdated(subscription)); }); } onUnsubscribed(channel_id) { - this.props.unsubscribed(channel_id); + const { dispatch } = this.props; + dispatch(unsubscribed(channel_id)); api.delete('subscriptions', { channel_id }); } @@ -199,6 +180,7 @@ SubscriptionsScene.propTypes = { subscriptionUpdated: React.PropTypes.func, subscriptionsFetching: React.PropTypes.func, subscriptionsFetched: React.PropTypes.func, + dispatch: React.PropTypes.func, }; /** @@ -221,30 +203,11 @@ function mapStateToProps(store) { }; } -/** - * Maps the methods exported from `action-creators.js` - * to `this.props.`. - * - * @param {function} dispatch - * @return {Object} - */ -function mapDispatchToProps() { - return { - channelsFetching: actions.channelsFetching, - channelsFetched: actions.channelsFetched, - subscriptionsFetching: actions.subscriptionsFetching, - subscriptionsFetched: actions.subscriptionsFetched, - subscribed: actions.subscribed, - unsubscribed: actions.unsubscribed, - subscriptionUpdated: actions.subscriptionUpdated, - }; -} - /** * Exports */ -module.exports = connect(mapStateToProps, mapDispatchToProps)(SubscriptionsScene); +module.exports = connect(mapStateToProps)(SubscriptionsScene); const styles = StyleSheet.create({ root: { diff --git a/package.json b/package.json index 35b80510..eafc9107 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "react-native-linear-gradient": "^1.5.13", "react-redux": "^4.4.5", "redux": "^3.5.2", + "redux-thunk": "^2.1.0", "stream": "0.0.2", "tinycolor2": "^1.3.0" }, diff --git a/test/__mocks__/react-native-google-analytics-bridge.js b/test/__mocks__/react-native-google-analytics-bridge.js new file mode 100644 index 00000000..5295bf74 --- /dev/null +++ b/test/__mocks__/react-native-google-analytics-bridge.js @@ -0,0 +1,5 @@ + +export default { + setTrackerId: () => {}, + trackEvent: () => {}, +}; diff --git a/test/lib/scanner/metadata/index.test.js b/test/lib/scanner/metadata/index.test.js deleted file mode 100644 index 3b580f17..00000000 --- a/test/lib/scanner/metadata/index.test.js +++ /dev/null @@ -1,93 +0,0 @@ - -/** - * Dependencies - */ - -const metadata = require('../../../../lib/scanner/metadata'); -const assert = require('assert'); - -jest.useFakeTimers(); - -describe('metadata', () => { - - describe('serverside', function() { - beforeEach(function() { - global.Request = jest.fn((url, config) => { - return Object.assign({}, config, { url }); - }); - - global.Headers = jest.fn(config => config); - - global.fetch = jest.fn(() => { - this.lastFetch = new Deferred; - return this.lastFetch.promise; - }); - }); - - afterEach(function() { - - }); - - describe('batches', function() { - beforeEach(function() { - this.calls = [ - metadata('http://bbc.co.uk/news'), - metadata('http://google.com'), - ]; - - // tick past batch window - jest.runTimersToTime(200); - }); - - it('batches requests', function() { - var request = global.fetch.mock.calls[0][0]; - var body = JSON.parse(request.body); - var urls = body.objects; - - assert.equal(urls[0].url, 'http://bbc.co.uk/news'); - assert.equal(urls[1].url, 'http://google.com'); - }); - - describe('error', function() { - beforeEach(function() { - var result = Promise.resolve([ - { - error: 'bad thing', - }, - { - title: 'Google', - }, - ]); - - this.lastFetch.resolve({ - json: () => result, - }); - - // wait till initial call has resolved - return this.calls[1]; - }); - - it('resolves the successful items', function() { - return this.calls[1] - .then(result => { - assert.equal(result.title, 'Google'); - }); - }); - - it('rejects the errored items', function() { - return this.calls[0] - .catch(err => { - assert.equal(err.message, 'bad thing'); - }); - }); - }); - }); - }); - - function Deferred() { - this.promise = new Promise((resolve, reject) => { - this.resolve = resolve; - this.reject = reject; - }); - } -}); diff --git a/test/lib/store.test.js b/test/lib/store.test.js index b5eadd9d..85cc86b2 100644 --- a/test/lib/store.test.js +++ b/test/lib/store.test.js @@ -3,14 +3,15 @@ * Dependencies */ -const actions = require('../../lib/store/actions').actions; -const { createStore, bindActionCreators } = require('redux'); -const reducer = require('../../lib/store/reducer'); -const assert = require('assert'); +import { createStore, bindActionCreators, applyMiddleware } from 'redux'; +import actions from '../../lib/store/actions'; +import reducer from '../../lib/store/reducer'; +import thunk from 'redux-thunk'; +import assert from 'assert'; describe('store', function() { beforeEach(function() { - this.store = createStore(reducer); + this.store = createStore(reducer, applyMiddleware(thunk)); this.actions = bindActionCreators(actions, this.store.dispatch); }); @@ -19,7 +20,7 @@ describe('store', function() { describe('first', function() { beforeEach(function() { - this.actions.updateItem(url, { + this.actions.itemFound(url, { url, id: url, distance: 3, @@ -27,80 +28,80 @@ describe('store', function() { }); it('sets correct distance', function() { - var item = getItem(this.store.getState().items, url); + var item = getItem(this.store.getState().items, url).value; var expected = [3]; - assert.deepEqual(item.distances, expected); - assert.equal(item.regulatedDistance, meanToNearest(expected, 2)); + assert.deepEqual(item._distanceHistory, expected); + assert.equal(item.distance, meanToNearest(expected, 2)); }); describe('second', function() { beforeEach(function() { - this.actions.updateItem(url, { distance: 4 }); + this.actions.itemFound(url, { distance: 4 }); }); it('sets correct distance', function() { - var item = getItem(this.store.getState().items, url); + var item = getItem(this.store.getState().items, url).value; var expected = [3, 4]; - assert.deepEqual(item.distances, expected); - assert.equal(item.regulatedDistance, meanToNearest(expected, 2)); + assert.deepEqual(item._distanceHistory, expected); + assert.equal(item.distance, meanToNearest(expected, 2)); }); describe('third', function() { beforeEach(function() { - this.actions.updateItem(url, { + this.actions.itemFound(url, { distance: 3, }); }); it('sets correct distance', function() { - var item = getItem(this.store.getState().items, url); + var item = getItem(this.store.getState().items, url).value; var expected = [3, 4, 3]; - assert.deepEqual(item.distances, expected); - assert.equal(item.regulatedDistance, meanToNearest(expected, 2)); + assert.deepEqual(item._distanceHistory, expected); + assert.equal(item.distance, meanToNearest(expected, 2)); }); describe('fourth', function() { beforeEach(function() { - this.actions.updateItem(url, { + this.actions.itemFound(url, { distance: 7, }); }); it('sets correct distance', function() { - var item = getItem(this.store.getState().items, url); + var item = getItem(this.store.getState().items, url).value; var expected = [3, 4, 3, 7]; - assert.deepEqual(item.distances, expected); - assert.equal(item.regulatedDistance, meanToNearest(expected, 2)); + assert.deepEqual(item._distanceHistory, expected); + assert.equal(item.distance, meanToNearest(expected, 2)); }); describe('fifth', function() { beforeEach(function() { - this.actions.updateItem(url, { distance: 4 }); + this.actions.itemFound(url, { distance: 4 }); }); it('sets correct distance', function() { - var item = getItem(this.store.getState().items, url); + var item = getItem(this.store.getState().items, url).value; var expected = [3, 4, 3, 7, 4]; - assert.deepEqual(item.distances, expected); - assert.equal(item.regulatedDistance, meanToNearest(expected, 2)); + assert.deepEqual(item._distanceHistory, expected); + assert.equal(item.distance, meanToNearest(expected, 2)); }); describe('sixth', function() { beforeEach(function() { - this.actions.updateItem(url, { distance: 4 }); + this.actions.itemFound(url, { distance: 4 }); }); it('sets correct distance', function() { - var item = getItem(this.store.getState().items, url); + var item = getItem(this.store.getState().items, url).value; var expected = [4, 3, 7, 4, 4]; - assert.deepEqual(item.distances, expected); - assert.equal(item.regulatedDistance, meanToNearest(expected, 2)); + assert.deepEqual(item._distanceHistory, expected); + assert.equal(item.distance, meanToNearest(expected, 2)); }); }); }); @@ -111,7 +112,7 @@ describe('store', function() { }); function getItem(items, id) { - return items.find(item => item.id === id); + return items[id]; } function meanToNearest(numbers, nearest) { diff --git a/test/lib/views/item-scene.test.js b/test/lib/views/item-scene.test.js index 0e1355dc..68c24195 100644 --- a/test/lib/views/item-scene.test.js +++ b/test/lib/views/item-scene.test.js @@ -1,9 +1,12 @@ -import 'react-native'; -import React from 'react'; -import ItemScene from '../../../lib/views/item-scene'; - +import ItemScene from '../../../lib/views/item/item-scene'; +import { createStore, applyMiddleware } from 'redux'; +import reducer from '../../../lib/store/reducer'; import renderer from 'react-test-renderer'; +import thunk from 'redux-thunk'; +import React from 'react'; +import 'react-native'; it('renders without crashing', () => { - renderer.create(); + const store = createStore(reducer, applyMiddleware(thunk)); + renderer.create(); }); diff --git a/yarn.lock b/yarn.lock index c9da7fb8..16cb9d75 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3537,10 +3537,9 @@ lru-cache@^4.0.0, lru-cache@^4.0.1: url "^0.11.0" xml2js "^0.4.16" -magnet-scanner-android@mozilla-magnet/magnet-scanner-android#v4.0.1, mozilla-magnet/magnet-scanner-android#v4.0.1: - name magnet-scanner-android - version "4.0.1" - resolved "https://codeload.github.com/mozilla-magnet/magnet-scanner-android/tar.gz/ebed682910b46e646afe385df5c2397cc3f6a6e5" +magnet-scanner-android@mozilla-magnet/magnet-scanner-android#v4.0.3: + version "4.0.3" + resolved "https://codeload.github.com/mozilla-magnet/magnet-scanner-android/tar.gz/155c7d338bd4d5b54d94beb582e11b24c6441fa4" makeerror@1.0.x: version "1.0.11" @@ -4192,9 +4191,9 @@ react-deep-force-update@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/react-deep-force-update/-/react-deep-force-update-1.0.1.tgz#f911b5be1d2a6fe387507dd6e9a767aa2924b4c7" -react-native-google-analytics-bridge@^3.0.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/react-native-google-analytics-bridge/-/react-native-google-analytics-bridge-3.1.0.tgz#4869db16e6e18d59f1f4ab6a33e483cc71872421" +react-native-google-analytics-bridge@mozilla-magnet/react-native-google-analytics-bridge#master: + version "4.0.0" + resolved "https://codeload.github.com/mozilla-magnet/react-native-google-analytics-bridge/tar.gz/eb4543509e9ab96b1bcc9b7e19942e2716330259" react-native-linear-gradient@^1.5.13: version "1.5.14" @@ -4305,7 +4304,7 @@ react-transform-hmr@^1.0.4: global "^4.3.0" react-proxy "^1.1.7" -react@^15.3.1: +react@15.3.x: version "15.3.2" resolved "https://registry.yarnpkg.com/react/-/react-15.3.2.tgz#a7bccd2fee8af126b0317e222c28d1d54528d09e" dependencies: @@ -4436,6 +4435,10 @@ reduce-component@1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/reduce-component/-/reduce-component-1.0.1.tgz#e0c93542c574521bea13df0f9488ed82ab77c5da" +redux-thunk: + version "2.1.0" + resolved "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-2.1.0.tgz#c724bfee75dbe352da2e3ba9bc14302badd89a98" + redux@^3.5.2: version "3.6.0" resolved "https://registry.yarnpkg.com/redux/-/redux-3.6.0.tgz#887c2b3d0b9bd86eca2be70571c27654c19e188d"