flashpoint
Version:
Angular bindings for Fireproof. Replaces AngularFire.
2,034 lines (1,397 loc) • 65.5 kB
JavaScript
/*! flashpoint 5.0.1, © 2015 J2H2 Inc. MIT License.
* https://github.com/casetext/flashpoint
*/
(function (root, factory) {
if (typeof define === 'function' && define.amd) {
// AMD. Register as an anonymous module.
define(['angular', 'firebase', 'fireproof'], factory);
} else if (typeof exports === 'object') {
// Node. Does not work with strict CommonJS, but
// only CommonJS-like environments that support module.exports,
// like Node.
factory(require('angular'), require('firebase'), require('fireproof'));
} else {
// Browser globals (root is window)
factory(root.angular, root.Firebase, root.Fireproof);
}
}(this, function (angular, Firebase, Fireproof) {
'use strict';
/**
* @ngdoc module
* @name flashpoint
*/
angular.module('flashpoint', []);
angular.module('flashpoint')
.directive('firebase', ["$animate", function($animate) {
/**
* @ngdoc directive
* @name firebase
* @description Wires Firebase into an Angular application.
*
* The `firebase` directive is an easy way to make the Firebase controller available
* to enclosing scope, where it is exposed as `fp`.
*
* @restrict A
* @element ANY
* @scope true
* @param {expression} firebase Full URL to the Firebase, like
* `https://my-firebase.firebaseio.com`. Interpolatable.
*/
function firebasePreLink(scope, el, attrs, fp) {
var attached, attachedUrl;
var attachToController = function(url) {
if (attached && url === attachedUrl) {
// already attached to this path, no action necessary
return;
} else if (typeof url !== 'string' || url === '') {
// no way to attach
return;
}
fp.attachFirebase(url);
attached = true;
attachedUrl = url;
};
attrs.$observe('firebase', attachToController);
scope.$watch('fp.connected', function(connected) {
if (connected === true) {
$animate.setClass(el, 'fp-connected', 'fp-disconnected');
} else if (connected === false) {
$animate.setClass(el, 'fp-disconnected', 'fp-connected');
} else {
$animate.setClass(el, '', 'fp-connected fp-disconnected');
}
});
scope.$watch('fp.auth', function(auth) {
if (auth === undefined) {
$animate.setClass(el, '', 'fp-unauthenticated fp-authenticated');
} else if (auth === null) {
$animate.setClass(el, 'fp-unauthenticated', 'fp-authenticated');
} else {
$animate.setClass(el, 'fp-authenticated', 'fp-unauthenticated');
}
});
scope.$watch('fp.authError', function(authError) {
if (authError) {
$animate.addClass(el, 'fp-auth-error');
} else {
$animate.removeClass(el, 'fp-auth-error');
}
});
scope.$watch('fp.accountError', function(accountError) {
if (accountError) {
$animate.addClass(el, 'fp-account-error');
} else {
$animate.removeClass(el, 'fp-account-error');
}
});
}
return {
restrict: 'A',
controller: 'FirebaseCtl',
controllerAs: 'fp',
priority: 1000,
scope: true,
link: {
pre: firebasePreLink
}
};
}]);
angular.module('flashpoint')
.directive('fpBindChildren', ["$animate", "$compile", "_fpGetRef", function($animate, $compile, _fpGetRef) {
/**
* @ngdoc directive
* @name fpBindChildren
* @description Binds DOM elements to the children of a Firebase path.
*
* @restrict A
* @element ANY
* @param {expression} fpBindChildren The annotated path to the children to use.
* @param {expression} query An expression that evaluates to a Firebase query.
*/
function fpBindChildrenCompile($templateEl) {
// search the template for fp-child-repeat, that's what we'll repeat on each child
var template = $templateEl[0].querySelector('[fp-child-repeat]'),
placeholder = angular.element(document.createElement('fp-child-repeat-placeholder'));
if (template) {
template = angular.element(template);
template.after(placeholder);
template.remove();
} else {
throw new Error('No fp-child-repeat was found in your fp-bind-children!');
}
return function fpBindChildrenLink(scope, el, attrs, fp) {
var startPlaceholder = angular.element(document.createComment('fp-child-repeat start')),
endPlaceholder = angular.element(document.createComment('fp-child-repeat end')),
oldPlaceholder = el.find('fp-child-repeat-placeholder');
oldPlaceholder.after(endPlaceholder);
oldPlaceholder.after(startPlaceholder);
oldPlaceholder.remove();
var query,
els = {},
cancelAttachListener;
scope.$children = [];
function find(key) {
// FIXME(goldibex): yes, I know this is linear time
for (var i = 0; i < scope.$children.length; i++) {
if (scope.$children[i].key() === key) {
return i;
}
}
return -1;
}
function _attach() {
if (cancelAttachListener) {
cancelAttachListener();
}
cancelAttachListener = scope.$watch(attrs.fpBindChildren, function(newQueryStr) {
_detach();
if (newQueryStr) {
try {
query = _fpGetRef(fp.root, newQueryStr);
query.on('child_added', onAdded, onError);
query.on('child_removed', onRemoved, onError);
query.on('child_moved', onMoved, onError);
query.on('child_changed', onChanged, onError);
} catch(e) {
onError(e);
}
}
});
}
function _detach() {
$animate.removeClass(el, 'fp-bind-children-error');
if (query) {
query.off('child_added', onAdded);
query.off('child_removed', onRemoved);
query.off('child_moved', onMoved);
query.off('child_changed', onChanged);
}
query = null;
scope.$children.forEach(function(child) {
var deadEl = els[child.key()];
deadEl.remove();
});
scope.$children.length = 0;
}
function onAdded(snap, prevKey) {
var position = find(prevKey)+1;
scope.$children.splice(position, 0, snap);
var clone = template.clone(),
cloneScope = scope.$new(),
cloneFn = $compile(clone);
// make sure the children know about flashpoint
cloneFn(cloneScope, null, {
transcludeControllers: {
firebase: { instance: fp }
}
});
var previousSibling = els[prevKey] || startPlaceholder;
cloneScope.$key = snap.key();
cloneScope.$value = snap.val();
cloneScope.$priority = snap.getPriority();
cloneScope.$index = position;
els[snap.key()] = angular.element(clone);
$animate.enter(clone, el.parent(), previousSibling);
}
function onRemoved(snap) {
$animate.leave(els[snap.key()]);
var position = find(snap.key());
scope.$children.splice(position, 1);
$animate.leave(els[snap.key()])
.then(function() {
els[snap.key()] = null;
});
}
function onMoved(snap, prevKey) {
var oldPosition = find(snap.key());
scope.$children.splice(oldPosition, 1);
var newPosition = find(prevKey) + 1;
scope.$children.splice(newPosition, 0, snap);
els[snap.key()].scope().$key = snap.key();
els[snap.key()].scope().$value = snap.val();
els[snap.key()].scope().$priority = snap.getPriority();
els[snap.key()].scope().$index = newPosition+1;
$animate.move(els[snap.key()], el.parent(), els[prevKey]);
}
function onChanged(snap) {
var position = find(snap.key());
scope.$children.splice(position, 1, snap);
els[snap.key()].scope().$key = snap.key();
els[snap.key()].scope().$value = snap.val();
els[snap.key()].scope().$priority = snap.getPriority();
$animate.addClass(els[snap.key()], 'fp-bind-children-changed')
.then(function() {
$animate.removeClass(els[snap.key()], 'fp-bind-children-changed');
});
}
function onError(err) {
scope.$error = err;
$animate.addClass(el, 'fp-bind-children-error');
}
var onAttachListener = fp.onAttach(_attach);
var onDetachListener = fp.onDetach(function() {
if (cancelAttachListener) {
cancelAttachListener();
cancelAttachListener = null;
}
_detach();
});
scope.$on('$destroy', function() {
// remove the comment nodes
startPlaceholder.remove();
endPlaceholder.remove();
if (cancelAttachListener) {
cancelAttachListener();
}
_detach();
fp.offAttach(onAttachListener);
fp.offDetach(onDetachListener);
});
};
}
return {
restrict: 'A',
priority: 900,
require: '^firebase',
compile: fpBindChildrenCompile
};
}]);
angular.module('flashpoint')
.directive('fpFeed', ["$q", "FPFeed", function($q, FPFeed) {
/**
* @ngdoc directive
* @name fpFeed
* @description Gathers objects from a variety of Firebase locations into a single array.
*
* The `fpFeed` directive queries a set of Firebase locations that you specify
* and pools all the retrieved values into a single list, available on scope as $page.
*
* @restrict A
* @element ANY
*
* @param {expression} fpFeed An expression that evaluates to an array of absolute
* paths in Firebase you wish to query, for instance, `["feeds/" + username, "feeds/firehose"]`.
*
* @param {expression} feedTransform An optional expression to transform a feed object
* into another linked object. It receives the
* special variables `$object` and `$root` and should return
* either the transformed object or a promise that resolves to the transformed object.
*
* @param {expression} feedFilter an optional expression run over each new feed object.
* It should return true if the object should be kept, false if not.
* Receives the special variables:
* - `$object`: the object under consideration.
* - `$index`: the proposed index of the new object.
* - `$newItems`: the set of all items about to be appended to the feed.
* - `$items`: the set of all items currently in the feed.
*
* @param {expression} feedSort an optional expression to be evaluated to sort the set of
* new objects before they're appended to the feed.
* Receives the special variables:
* - `$a` or `$left`: the left-hand object in the comparison
* - `$b` or `$right`: the right-hand object in the comparison
* Defaults to sorting by key.
*/
function fpFeedLink(scope, el, attrs, fp) {
var onAttachListener = fp.onAttach(function(root) {
scope.$feed = new FPFeed(scope.$eval(attrs.fpFeed), function(ref, start) {
if (attrs.feedQuery) {
return scope.$eval(attrs.feedQuery, { $ref: ref, $start: start });
} else {
throw new Error('fp-feed requires feed-query to be set');
}
});
scope.$feed.setTransform(function(object, root) {
if (attrs.feedTransform) {
return scope.$eval(attrs.feedTransform, {
'$object': object,
'$root': root
});
} else {
return object;
}
});
scope.$feed.setFilter(function(object, index, newItems, items) {
if (attrs.feedFilter) {
return scope.$eval(attrs.feedFilter, {
'$object': object,
'$index': index,
'$newItems': newItems,
'$items': items
});
} else {
return function() { return true; };
}
});
scope.$feed.setSort(function(a, b) {
if (attrs.feedSort) {
return scope.$eval(attrs.feedSort, {
'$a': a,
'$b': b,
'$left': a,
'$right': b
});
} else {
return a.ref().toString().localeCompare(b.ref().toString());
}
});
scope.$feed.connect(root);
});
var onDetachListener = fp.onDetach(function() {
if (scope.$feed) {
scope.$feed.disconnect();
}
});
scope.$on('$destroy', function() {
fp.offAttach(onAttachListener);
fp.offDetach(onDetachListener);
scope.$feed.disconnect();
});
}
return {
require: '^firebase',
link: fpFeedLink
};
}]);
angular.module('flashpoint')
.constant('FP_DEFAULT_PAGE_SIZE', 3)
.directive('fpPage', ["FP_DEFAULT_PAGE_SIZE", "FPPage", function(FP_DEFAULT_PAGE_SIZE, FPPage) {
/**
* @ngdoc directive
* @name fpPage
* @description Pages over the children of a given Firebase location.
*
* The `fpPage` directive makes it easy to page over the static children of a
* given Firebase location. Since, as the Firebase docs describe, the operation of
* paging over data that changes in real-time is not well-defined, this directive
* should only be used with Firebase data that changes rarely or not at all.
* After instantiation, scope will have a variable `$page` of type {link Page}
* that you can use.
*
* @restrict A
* @element ANY
*
* @param {expression} fpPage An expression that evaluates to a Firebase query to
* be used to retrieve items. Some special functions are provided:
* - `$orderByPriority(path, size)`: order the children of `path` by priority,
* and provide at most `size` objects per page.
* - `$orderByKey(path, size)`: order the children of `path` by key,
* and provide at most `size` objects per page.
* - `$orderByValue(path, size)`: order the children of `path` by priority,
* and provide at most `size` objects per page.
* - `$orderByValue(path, child, size)`: order the children of `path` by the value of child `child`,
* and provide at most `size` objects per page.
* @see {Page}
*/
function fpPageLink(scope, el, attrs, fp) {
function $orderByPriority(path, size) {
size = parseInt(size);
if (isNaN(size)) {
size = FP_DEFAULT_PAGE_SIZE;
}
return function(root, last) {
var query = root.child(path).orderByPriority();
if (last) {
return query.startAt(last.getPriority(), last.key()).limitToFirst(size+1);
} else {
return query.limitToFirst(size);
}
};
}
function $orderByKey(path, size) {
size = parseInt(size);
if (isNaN(size)) {
size = FP_DEFAULT_PAGE_SIZE;
}
return function(root, last) {
var query = root.child(path).orderByKey();
if (last) {
return query.startAt(last.key()).limitToFirst(size+1);
} else {
return query.limitToFirst(size);
}
};
}
function $orderByValue(path, size) {
size = parseInt(size);
if (isNaN(size)) {
size = FP_DEFAULT_PAGE_SIZE;
}
return function(root, last) {
var query = root.child(path).orderByValue();
if (last) {
return query.startAt(last.val(), last.key()).limitToFirst(size+1);
} else {
return query.limitToFirst(size);
}
};
}
function $orderByChild(path, child, size) {
size = parseInt(size);
if (isNaN(size)) {
size = FP_DEFAULT_PAGE_SIZE;
}
return function(root, last) {
var query = root.child(path).orderByChild(child);
if (last) {
var lastVal = last.val();
if (angular.isObject(lastVal)) {
lastVal = lastVal[child];
} else {
lastVal = null;
}
return query.startAt(lastVal, last.key()).limitToFirst(size+1);
} else {
return query.limitToFirst(size);
}
};
}
var orderingMethods = {
$orderByPriority: $orderByPriority,
$orderByKey: $orderByKey,
$orderByChild: $orderByChild,
$orderByValue: $orderByValue
};
var onAttachListener = fp.onAttach(function(root) {
scope.$page = new FPPage(scope.$eval(attrs.fpPage, orderingMethods));
scope.$page.connect(root);
});
var onDetachListener = fp.onDetach(function() {
if (scope.$page) {
scope.$page.disconnect();
}
});
scope.$on('$destroy', function() {
fp.offAttach(onAttachListener);
fp.offDetach(onDetachListener);
scope.$page.disconnect();
});
}
return {
link: fpPageLink,
restrict: 'A',
priority: 750,
require: '^firebase'
};
}]);
angular.module('flashpoint')
.directive('onAuth', function() {
/**
* @ngdoc directive
* @name onAuth
* @description Evaluates an Angular expression on changes in authentication status.
*
* The `onAuth` directive hooks into Firebase's `onAuth` expression and evaluates
* the expression you supply every time authentication status against your Firebase
* changes. This is useful for managing login state. It supplies the special variable
* `$auth` to your expression.
*
* @restrict A
* @element ANY
*/
function onAuthPreLink(scope, el, attrs, fp) {
function authHandler(authData) {
if (attrs.onAuth) {
scope.$eval(attrs.onAuth, { $auth: authData });
}
}
fp.onAttach(function(root) {
root.onAuth(authHandler);
});
fp.onDetach(function(root) {
if (root) {
root.offAuth(authHandler);
}
});
}
return {
priority: 750,
require: '^firebase',
restrict: 'A',
link: {
pre: onAuthPreLink
}
};
});
angular.module('flashpoint')
.directive('onConnect', function() {
/**
* @ngdoc directive
* @name onConnect
* @description Evaluates the given expression on successfully establishing a Firebase connection.
* Must be supplied together with `firebase`.
*
* The `onConnect` directive evaluates the expression you supply whenever the
* connection to the Firebase is re-established.
*
* @restrict A
* @element ANY
*/
return {
require: 'firebase',
link: fpOnConnectLink
};
});
function fpOnConnectLink(scope, el, attrs, fp) {
var cancel;
var attachListener = fp.onAttach(function() {
cancel = scope.$watch('fp.connected', function(connected) {
if (connected === true) {
scope.$eval(attrs.onConnect);
}
});
});
var detachListener = fp.onDetach(function() {
if (cancel) {
cancel();
cancel = null;
}
});
scope.$on('$destroy', function() {
fp.offAttach(attachListener);
fp.offDetach(detachListener);
});
}
angular.module('flashpoint')
.directive('onDisconnect', ["$q", "$log", "fpValidatePath", function($q, $log, fpValidatePath) {
/**
* @ngdoc directive
* @name onDisconnect
* @description Sets a Firebase onDisconnect hook. Must be supplied together with `firebase`.
*
* Firebase provides a way to make changes to a database in case the user disconnects,
* known as "onDisconnect". The `onDisconnect` directive exposes onDisconnect
* to Angular expressions.
*
* The `onDisconnect` expression adds the behavior that when you _detach_ from a Firebase,
* the expression is also evaluated.
*
* NB: `onDisconnect` IS NOT EVALUATED WHEN THE FIREBASE ACTUALLY DISCONNECTS!
* Instead, it's the equivalent of telling Firebase, "Hey, if you don't hear back
* from me in a while, do this operation for me." The expression actually gets
* evaluated right after a successful connection to Firebase.
*
* The supplied expression gets access to the special functions `$set`,
* `$update`, `$setWithPriority`, and `$remove`, all of which behave identically
* to their counterparts in Firebase using Flashpoint syntax.
* For instance, ```on-disconnect="$remove('online-users', $auth.name)"```.
*
* @restrict A
* @element ANY
*/
function onDisconnectLink(scope, el, attrs, fp) {
var disconnects = {};
var onDisconnectError = function(err) {
$log.debug('onDisconnect: error evaluating "' + attrs.onDisconnect +
'": ' + err.code);
if (attrs.onDisconnectError) {
scope.$eval(attrs.onDisconnectError, { $error: err });
}
};
var getDisconnectContext = function(root) {
return {
$set: function() {
var args = Array.prototype.slice.call(arguments, 0),
data = args.pop(),
path = fpValidatePath(args);
if (path) {
disconnects[path] = true;
return root.child(path).onDisconnect().set(data)
.catch(onDisconnectError);
} else {
return $q.reject(new Error('Invalid path'));
}
},
$update: function() {
var args = Array.prototype.slice.call(arguments, 0),
data = args.pop(),
path = fpValidatePath(args);
if (path) {
disconnects[path] = true;
return root.child(path).onDisconnect().update(data)
.catch(onDisconnectError);
} else {
return $q.reject(new Error('Invalid path'));
}
},
$setWithPriority: function() {
var args = Array.prototype.slice.call(arguments, 0),
priority = args.pop(),
data = args.pop(),
path = fpValidatePath(args);
if (path) {
disconnects[path] = true;
return root.child(path).onDisconnect().setWithPriority(data, priority)
.catch(onDisconnectError);
} else {
return $q.reject(new Error('Invalid path'));
}
},
$remove: function() {
var args = Array.prototype.slice.call(arguments, 0),
path = fpValidatePath(args);
if (path) {
disconnects[path] = true;
return root.child(path).onDisconnect().remove()
.catch(onDisconnectError);
} else {
return $q.reject(new Error('Invalid path'));
}
}
};
};
var liveContext = {
$set: fp.set.bind(fp),
$remove: fp.remove.bind(fp),
$setWithPriority: fp.setWithPriority.bind(fp),
$update: fp.update.bind(fp)
};
var attachListener = fp.onAttach(function(root) {
// attach disconnect to this Firebase
scope.$eval(attrs.onDisconnect, getDisconnectContext(root));
});
var detachListener = fp.onDetach(function(root) {
for (var disconnectPath in disconnects) {
// cancel the disconnect expression, then actually run it
// (because detaching is effectively disconnecting)
root.child(disconnectPath).onDisconnect().cancel();
scope.$eval(attrs.onDisconnect, liveContext);
}
disconnects = {};
});
scope.$on('$destroy', function() {
fp.offAttach(attachListener);
fp.offDetach(detachListener);
});
}
return {
require: 'firebase',
restrict: 'A',
link: onDisconnectLink
};
}]);
angular.module('flashpoint')
.filter('orderByKey', function() {
return function orderByKey(path) {
if (angular.isArray(path)) {
return path.map(orderByKey);
} else if (angular.isString(path)) {
return path + '.orderByKey';
} else {
return null;
}
};
})
.filter('orderByValue', function() {
return function orderByValue(path) {
if (angular.isArray(path)) {
return path.map(orderByValue);
} else if (angular.isString(path)) {
return path + '.orderByValue';
} else {
return null;
}
};
})
.filter('orderByPriority', function() {
return function orderByPriority(path) {
if (angular.isArray(path)) {
return path.map(orderByPriority);
} else if (angular.isString(path)) {
return path + '.orderByPriority';
} else {
return null;
}
};
})
.filter('orderByChild', function() {
return function orderByChild(path, child) {
if (angular.isArray(path)) {
return path.map(function(pathItem) {
return orderByChild(pathItem, child);
});
} else if (angular.isString(path) && angular.isString(child) && child.length > 0) {
return path + '.orderByChild:' + JSON.stringify(child);
} else {
return null;
}
};
})
.filter('startAt', function() {
return function startAt(path, startAtValue, startAtKey) {
if (angular.isArray(path)) {
return path.map(function(pathItem) {
return startAt(pathItem, startAtValue, startAtKey);
});
} else {
if (startAtValue === undefined) {
return null;
} else {
path += '.startAt:' + JSON.stringify(startAtValue);
}
if (angular.isString(startAtKey) || angular.isNumber(startAtKey)) {
return path + ':' + JSON.stringify(startAtKey);
} else {
return path;
}
}
};
})
.filter('endAt', function() {
return function endAt(path, endAtValue, endAtKey) {
if (angular.isArray(path)) {
return path.map(function(pathItem) {
return endAt(pathItem, endAtValue, endAtKey);
});
} else {
if (endAtValue === undefined) {
return null;
} else {
path += '.endAt:' + JSON.stringify(endAtValue);
}
if (angular.isString(endAtKey) || angular.isNumber(endAtKey)) {
return path + ':' + JSON.stringify(endAtKey);
} else {
return path;
}
}
};
})
.filter('limitToFirst', function() {
return function limitToFirst(path, limitToFirstQuantity) {
limitToFirstQuantity = parseInt(limitToFirstQuantity);
if (isNaN(limitToFirstQuantity)) {
return null;
} else if (angular.isArray(path)) {
return path.map(function(pathItem) {
return limitToFirst(pathItem, limitToFirstQuantity);
});
} else {
return path + '.limitToFirst:' + JSON.stringify(limitToFirstQuantity);
}
};
})
.filter('limitToLast', function() {
return function limitToLast(path, limitToLastQuantity) {
limitToLastQuantity = parseInt(limitToLastQuantity);
if (isNaN(limitToLastQuantity)) {
return null;
} else if (angular.isArray(path)) {
return path.map(function(pathItem) {
return limitToLast(pathItem, limitToLastQuantity);
});
} else {
return path + '.limitToLast:' + JSON.stringify(limitToLastQuantity);
}
};
})
.constant('_fpGetRef', function _fpGetRef(root, path) {
if (angular.isArray(path)) {
return path.map(function(pathItem) {
return _fpGetRef(root, pathItem);
});
} else if (angular.isString(path)) {
var params = path.split(/\./g),
query = root.child(params.shift());
return params.reduce(function(query, part) {
var name = part.split(':')[0],
args = part.split(':').slice(1).map(function(item) { return JSON.parse(item); });
switch(name) {
case 'orderByKey':
case 'orderByValue':
case 'orderByPriority':
return query[name]();
case 'orderByChild':
case 'startAt':
case 'endAt':
case 'limitToFirst':
case 'limitToLast':
return query[name].apply(query, args);
}
}, query);
} else {
return null;
}
});
angular.module('flashpoint')
.factory('FPFeed', ["$q", function($q) {
function FPFeed(paths, queryFn) {
if (arguments.length < 2) {
throw new Error('FPFeed expects at least 2 arguments, got ' + arguments.length);
}
if (!angular.isArray(paths)) {
paths = [paths];
}
this._queryFn = queryFn;
this._positions = paths.reduce(function(obj, path) {
obj[path] = undefined;
return obj;
}, {});
this._presence = {};
this.items = [];
}
FPFeed.prototype._transformFn = function(obj) {
return obj;
};
FPFeed.prototype._filterFn = function() {
return true;
};
FPFeed.prototype.setTransform = function(fn) {
this._transformFn = fn;
return this;
};
FPFeed.prototype.setSort = function(fn) {
this._sortFn = fn;
return this;
};
FPFeed.prototype.setFilter = function(fn) {
this._filterFn = fn;
return this;
};
FPFeed.prototype.more = function() {
var self = this;
if (!self._morePromise) {
self._morePromise = $q.all(Object.keys(self._positions).map(function(path) {
return self._queryFn(self.root.child(path), self._positions[path])
.then(function(snap) {
var promises = [];
snap.forEach(function(feedEntry) {
if (!self._presence.hasOwnProperty(feedEntry.ref().toString())) {
self._presence[feedEntry.ref().toString()] = true;
promises.push($q.when(self._transformFn(feedEntry, self.root)));
self._positions[path] = feedEntry;
}
});
return $q.all(promises);
});
}))
.then(function(feedResultsArrays) {
var allResults = feedResultsArrays
.reduce(function(allResults, feedResultArray) {
return allResults.concat(feedResultArray);
}, [])
.filter(function(object, index, array) {
return self._filterFn(object, index, array, self.items);
});
if (self._sortFn) {
allResults.sort(self._sortFn);
}
allResults.forEach(function(result) {
self.items.push(result);
});
self._morePromise = null;
});
}
return self._morePromise;
};
FPFeed.prototype.connect = function(root) {
this.disconnect();
this.root = root;
return this.more();
};
FPFeed.prototype.disconnect = function() {
this._positions = Object.keys(this._positions).reduce(function(obj, path) {
obj[path] = undefined;
return obj;
}, {});
this._presence = {};
this.items.length = 0;
};
return FPFeed;
}]);
angular.module('flashpoint')
.factory('FPListenerSet', function() {
function FPListenerSet(root, scope) {
var self = this,
scrubbingListeners = false;
self.watchers = {};
self.liveWatchers = {};
self.values = {};
self.priorities = {};
self.errors = {};
self.root = root;
self.scope = scope;
function scrubListeners() {
var newWatchers = {};
for (var path in self.watchers) {
if (self.watchers[path] && self.liveWatchers[path]) {
newWatchers[path] = self.watchers[path];
} else {
self.remove(path);
}
}
// as of now, nothing is alive.
self.watchers = newWatchers;
self.liveWatchers = {};
scrubbingListeners = false;
}
self.scope.$watch(function() {
// after each scope cycle, sweep out any "orphaned" listeners, i.e.,
// ones we previously connected but don't need anymore.
if (!scrubbingListeners) {
scrubbingListeners = true;
scope.$$postDigest(scrubListeners);
}
});
}
FPListenerSet.prototype.add = function(path) {
var self = this;
self.liveWatchers[path] = true;
if (!self.watchers[path]) {
self.watchers[path] = self.root.child(path)
.on('value', function(snap) {
self.errors[path] = null;
self.values[path] = snap.val();
self.priorities[path] = snap.getPriority();
self.scope.$evalAsync();
}, function(err) {
self.liveWatchers[path] = false;
self.watchers[path] = null;
self.errors[path] = err;
self.values[path] = null;
self.priorities[path] = null;
self.scope.$evalAsync();
});
}
};
FPListenerSet.prototype.has = function(path) {
return this.watchers.hasOwnProperty(path);
};
FPListenerSet.prototype.remove = function(path) {
if (this.watchers[path]) {
// disconnect this watcher, it doesn't exist anymore.
if (this.watchers[path].disconnect) {
this.watchers[path].disconnect();
} else {
this.root.child(path).off('value', this.watchers[path]);
}
// clear all values associated with the watcher
this.values[path] = null;
this.errors[path] = null;
this.priorities[path] = null;
this.watchers[path] = null;
}
};
FPListenerSet.prototype.clear = function() {
for (var path in this.watchers) {
this.remove(path);
}
this.watchers = {};
};
return FPListenerSet;
});
angular.module('flashpoint')
.factory('FPPage', ["$q", function($q) {
function FPPage(pagingFn) {
this._pagingFn = pagingFn;
this._pages = [];
this._presence = {};
this.items = [];
this.keys = [];
this.priorities = [];
this.values = [];
this.disconnect();
}
FPPage.prototype.connect = function(root) {
this.disconnect();
this.root = root;
return this.next();
};
FPPage.prototype.disconnect = function() {
this.root = null;
this._pages.length = 0;
this.items.length = 0;
this.keys.length = 0;
this.values.length = 0;
this.priorities.length = 0;
this.number = 0;
this._presence = {};
};
FPPage.prototype._handleSnap = function(snap) {
var newFPPage = [],
presence = this._presence;
snap.forEach(function(child) {
if (!presence.hasOwnProperty(child.key()) ) {
presence[child.key()] = true;
newFPPage.push(child);
}
});
if (newFPPage.length > 0) {
this._pages.push(newFPPage);
this._setFPPage(this._pages.length);
} else {
this._lastFPPage = this.number;
}
this._currentOperation = null;
};
FPPage.prototype.next = function() {
if (!this.hasNext()) {
// nothing to set.
return $q.when();
} else if (this._pages.length > this.number) {
// we already have the page, just copy its contents into this.items
this._setFPPage(this.number+1);
return $q.when();
} else if (this._currentOperation) {
return this._currentOperation;
} else if (this._pages.length === 0) {
this._currentOperation = this._pagingFn(this.root, null)
.then(this._handleSnap.bind(this));
return this._currentOperation;
} else if (this._pages[this._pages.length-1].length > 0) {
var lastFPPage = this._pages[this._pages.length-1];
this._currentOperation = this._pagingFn(this.root, lastFPPage[lastFPPage.length-1])
.then(this._handleSnap.bind(this));
return this._currentOperation;
} else {
return $q.when();
}
};
FPPage.prototype.hasNext = function() {
return this.hasOwnProperty('root') && this._lastFPPage !== this.number;
};
FPPage.prototype.hasPrevious = function() {
return this.hasOwnProperty('root') && this.number > 1;
};
FPPage.prototype.previous = function() {
if (this.hasPrevious()) {
this._setFPPage(this.number-1);
}
return $q.when();
};
FPPage.prototype.reset = function() {
return this.connect(this.root);
};
FPPage.prototype._setFPPage = function(pageNumber) {
this.items.length = 0;
this.keys.length = 0;
this.values.length = 0;
this.priorities.length = 0;
this._pages[pageNumber-1].forEach(function(item) {
this.items.push(item);
this.keys.push(item.key());
this.values.push(item.val());
this.priorities.push(item.getPriority());
}, this);
this.number = pageNumber;
};
return FPPage;
}]);
angular.module('flashpoint')
.factory('Firebase', function() {
/**
* @ngdoc service
* @name Firebase
* @description The Firebase class.
*
* The Firebase library exposes this on window by default, but using Angular DI
* allows it to be mocked or modified if you wish.
*
* NB: You should not use this service yourself! Instead, use the firebase
* directive and write your own directives to require it, then access its
* `root` Firebase reference.
*
* @see {@link FirebaseCtl#cleanup}
*/
return Firebase;
})
.factory('ServerValue', ["Firebase", function(Firebase) {
/**
* @ngdoc service
* @name ServerValue
* @description The object ordinarily discovered on `Firebase.ServerValue`.
*
* Available for convenience.
* @see {@link Firebase}
*/
return Firebase.ServerValue;
}])
.factory('Fireproof', ["$rootScope", "$q", function($rootScope, $q) {
/**
* @ngdoc service
* @name Fireproof
* @description The Fireproof class, properly configured for use in Angular.
*
* "Properly configured" means that $rootScope.$evalAsync is used for nextTick and
* Angular's $q is used for promises).
*
* NB: You should not use this service yourself! Instead, use the firebase
* directive and write your own directives to require it, then access its
* `root` Firebase reference.
*/
Fireproof.setNextTick(function(fn) {
$rootScope.$evalAsync(fn);
});
Fireproof.bless($q);
return Fireproof;
}]);
angular.module('flashpoint')
.factory('fpValidatePath', function() {
function fpValidatePath(pathParts) {
// check the arguments
var path = pathParts.join('/');
if (pathParts.length === 0 || path === '' ||
pathParts.indexOf(null) !== -1 || pathParts.indexOf(undefined) !== -1) {
// if any one of them is null/undefined, this is not a valid path
return null;
} else {
return path;
}
}
return fpValidatePath;
});
function FirebaseCtl(
$scope,
$q,
Firebase,
Fireproof,
fpValidatePath,
FPListenerSet) {
/**
* @ngdoc type
* @name FirebaseCtl
* @module flashpoint
* @description The core controller responsible for binding
* Firebase data into Angular.
*
* @property {Firebase} root The root of the instantiated Firebase store.
*
* @property {Boolean} connected The state of the network connection to Firebase.
* This will be:
* - `true`, if there is a good network connection to Firebase
* - `false`, if the connection to Firebase is interrupted or not available
* - `undefined` if the connection state is not known
*
* @property {Object} auth The authentication data from Firebase. This will be:
* - `null`, if the user is not authenticated
* - `undefined`, if the authentication state is not yet known
* - an `Object`, containing information about the currently-authenticated user
*
* @property {Error} authError The error reported by the most recent attempt to
* authenticate to Firebase, or `null` otherwise.
*
* @property {Error} accountError The error reported by the most recent attempt
* to perform an account-related action on Firebase, or `null` otherwise.
*
* @property {Boolean} accountChanging True if an account-changing action
* (password reset, user delete, etc.) is in progress, false otherwise.
*
* @property {Boolean} authenticating True if an authentication attempt is
* in progress, false otherwise.
*/
var self = this;
var _attachListeners = [],
_detachListeners = [];
self.auth = null;
self.authError = null;
self.accountError = null;
self.authenticating = false;
self.accountChanging = false;
function authHandler(authData) {
if (self.listenerSet) {
self.listenerSet.clear();
}
self.auth = authData;
$scope.$evalAsync();
}
function connectedListener(snap) {
self.connected = snap.val();
$scope.$evalAsync();
}
function authPassHandler(auth) {
self.authenticating = false;
self.authError = null;
return auth;
}
function authErrorHandler(err) {
self.authenticating = true;
self.authError = err;
return $q.reject(err);
}
function accountPassHandler() {
self.accountChanging = false;
self.accountError = null;
}
function accountErrorHandler(err) {
self.accountChanging = false;
self.accountError = err;
return $q.reject(err);
}
/**
* @ngdoc method
* @name FirebaseCtl#detachFirebase
* @description Removes and detaches all connections to Firebase used by
* this controller.
*/
self.detachFirebase = function() {
// detach all watchers
if (self.listenerSet) {
self.listenerSet.clear();
delete self.listenerSet;
}
delete self.connected;
self.auth = null;
self.authError = null;
self.accountError = null;
self.authenticating = false;
self.accountChanging = false;
if (self.root) {
// detach any remaining listeners here.
self.root.offAuth(authHandler);
self.root.child('.info/connected').off('value', connectedListener);
self.root.off();
_detachListeners.forEach(function(listener) {
listener(self.root);
});
// remove the actual root object itself, as it's now invalid.
delete self.root;
}
$scope.$evalAsync();
};
self.onDetach = function(fn) {
_detachListeners.push(fn);
if (!self.root) {
fn();
}
return fn;
};
self.offDetach = function(fn) {
_detachListeners.splice(_detachListeners.indexOf(fn), 1);
};
/**
* @ngdoc method
* @name FirebaseCtl#attachFirebase
* @description Connects to the specified Firebase.
* @param {string} url The full URL of the Firebase to connect to.
*/
self.attachFirebase = function(url) {
// if we already have a root, make sure to clean it up first
if (self.root) {
self.detachFirebase();
}
self.root = new Fireproof(new Firebase(url));
self.listenerSet = new FPListenerSet(self.root, $scope);
self.root.onAuth(authHandler);
// maintain knowledge of connection status
// we assume, optimistically, that we're connected initially
self.connected = true;
self.root.child('.info/connected')
.on('value', connectedListener);
_attachListeners.forEach(function(listener) {
listener(self.root);
});
};
self.onAttach = function(fn) {
_attachListeners.push(fn);
if (self.root) {
fn(self.root);
}
return fn;
};
self.offAttach = function(fn) {
_attachListeners.splice(_attachListeners.indexOf(fn), 1);
};
/**
* @ngdoc method
* @name FirebaseCtl#goOffline
* @description Disables the connection to the remote Firebase server. NOTE:
* this method affects _all_ FirebaseCtl instances on the page.
* @see Firebase.goOffline
*/
self.goOffline = function() {
Firebase.goOffline();
};
/**
* @ngdoc method
* @name FirebaseCtl#goOnline
* @description Enables the connection to the remote Firebase server. NOTE:
* this method affects _all_ FirebaseCtl instances on the page.
* @see Firebase.goOnline
*/
self.goOnline = function() {
Firebase.goOnline();
};
/**
* @ngdoc method
* @name FirebaseCtl#unauth
* @description Unauthenticates (i.e., logs out) the Firebase connection.
* @see Fireproof#unauth
*/
self.unauth = function() {
self.authError = null;
self.accountError = null;
self.root.unauth();
};
/**
* @ngdoc method
* @name FirebaseCtl#authWithCustomToken
* @description Authenticates using a custom token or Firebase secret.
* @param {String} token The token to authenticate with.
* @returns {Promise} that resolves on success and rejects on error.
* @see Fireproof#authWithCustomToken
*/
self.authWithCustomToken = function(token) {
self.authenticating = true;
return self.root.authWithCustomToken(token)
.then(authPassHandler, authErrorHandler);
};
/**
* @ngdoc method
* @name FirebaseCtl#authAnonymously
* @description Authenticates using a new, temporary guest account.
* @param {Object} options
* @returns {Promise} that resolves on success and rejects on error.
* @see Fireproof#authAnonymously
*/
self.authAnonymously = function(options) {
self.authenticating = true;
return self.root.authAnonymously(null, options)
.then(authPassHandler, authErrorHandler);
};
/**
* @ngdoc method
* @name FirebaseCtl#authWithPassword
* @description Authenticates using an email / password combination.
* @param {String} email
* @param {String} password
* @returns {Promise} that resolves on success and rejects on error.
* @see Fireproof#authWithPassword
*/
self.authWithPassword = function(email, password) {
self.authenticating = true;
return self.root.authWithPassword({ email: email, password: password })
.then(authPassHandler, authErrorHandler);
};
/**
* @ngdoc method
* @name FirebaseCtl#authWithOAuthPopup
* @description Authenticates using a popup-based OAuth flow.
* @param {String} provider
* @param {Object} options
* @returns {Promise} that resolves on success and rejects on error.
* @see Fireproof#authWithOAuthPopup
*/
self.authWithOAuthPopup = function(provider, options) {
self.authenticating = true;
return self.root.authWithOAuthPopup(provider, null, options)
.then(authPassHandler, authErrorHandler);
};
/**
* @ngdoc method
* @name FirebaseCtl#authWithOAuthToken
* @description Authenticates using OAuth access tokens or credentials.
* @param {String} provider
* @param {Object} credentials
* @param {Object} options
* @returns {Promise} that resolves on success and rejects on error.
* @see Fireproof#authWithOAuthToken
*/
self.authWithOAuthToken = function(provider, credentials, options) {
self.authenticating = true;
return self.root.authWithOAuthToken(provider, credentials, null, options)
.then(authPassHandler, authErrorHandler);
};
/**
* @ngdoc method
* @name FirebaseCtl#createUser
* @description Creates a new user account using