diff --git a/.jshintignore b/.jshintignore index f30e0a7..f06f812 100644 --- a/.jshintignore +++ b/.jshintignore @@ -1,4 +1,5 @@ node_modules -clientapp/libraries -clientapp/modules public +clientapp/libraries +clientapp/templates.js +clientapp/.build diff --git a/.jshintrc b/.jshintrc index 9cfa73d..006e4c0 100644 --- a/.jshintrc +++ b/.jshintrc @@ -7,17 +7,9 @@ "white": true, "undef": true, "browser": true, - "es5": true, - "predef": [ - "$", - "me", - "confirm", - "alert", - "require", - "__dirname", - "process", - "exports", - "Buffer", - "module" - ] + "node": true, + "trailing": true, + "indent": 4, + "latedef": true, + "newcap": true } diff --git a/clientapp/app.js b/clientapp/app.js new file mode 100644 index 0000000..d1226d5 --- /dev/null +++ b/clientapp/app.js @@ -0,0 +1,86 @@ +/*global $, app, me, client, XMPP*/ +"use strict"; + +var _ = require('underscore'); +var async = require('async'); +var Backbone = require('backbone'); +var MeModel = require('./models/me'); +var MainView = require('./views/main'); +var Router = require('./router'); +var Storage = require('./storage'); +var xmppEventHandlers = require('./helpers/xmppEventHandlers'); + + +module.exports = { + launch: function () { + var self = this; + + _.extend(this, Backbone.Events); + + var app = window.app = this; + + $(function () { + async.series([ + function (cb) { + app.storage = new Storage(); + app.storage.open(cb); + }, + function (cb) { + var me = window.me = new MeModel(); + + new Router(); + app.history = Backbone.history; + + if (!localStorage.config) { + return app.navigate('signin'); + } + + app.view = new MainView({ + model: me, + el: document.body + }); + app.view.render(); + + var rosterVer = localStorage.rosterVersion; + var config = JSON.parse(localStorage.config); + + config.rosterVer = rosterVer; + + var client = window.client = app.client = XMPP.createClient(config); + xmppEventHandlers(client, app); + client.connect(); + + // we have what we need, we can now start our router and show the appropriate page + app.history.start({pushState: true, root: '/'}); + + cb(); + } + ]); + }); + }, + whenConnected: function (func) { + if (client.sessionStarted) { + func(); + } else { + client.once('session:started', func); + } + }, + navigate: function (page) { + var url = (page.charAt(0) === '/') ? page.slice(1) : page; + app.history.navigate(url, true); + }, + renderPage: function (view, animation) { + var container = $('#pages'); + + if (app.currentPage) { + app.currentPage.hide(animation); + } + // we call render, but if animation is none, we want to tell the view + // to start with the active class already before appending to DOM. + container.append(view.render(animation === 'none').el); + view.show(animation); + } +}; + + +module.exports.launch(); diff --git a/clientapp/app/app.js b/clientapp/app/app.js deleted file mode 100644 index 23d5619..0000000 --- a/clientapp/app/app.js +++ /dev/null @@ -1,49 +0,0 @@ -/* global app, XMPP */ -var Backbone = require('backbone'); -var MeModel = require('models/me'); -var MainView = require('views/main'); -var Router = require('router'); -var xmppEventHandlers = require('helpers/xmppEventHandlers'); - - -module.exports = { - launch: function () { - var app = window.app = this; - var me = window.me = new MeModel(); - - new Router(); - app.history = Backbone.history; - - if (!localStorage.config) { - return app.navigate('signin'); - } - - app.view = new MainView({ - el: document.body, - model: me - }).render(); - - var config = JSON.parse(localStorage.config); - var client = window.client = app.client = XMPP.createClient(config); - xmppEventHandlers(client, app); - client.connect(); - - // we have what we need, we can now start our router and show the appropriate page - app.history.start({pushState: true, root: '/'}); - }, - navigate: function (page) { - var url = (page.charAt(0) === '/') ? page.slice(1) : page; - app.history.navigate(url, true); - }, - renderPage: function (view, animation) { - var container = $('#pages'); - - if (app.currentPage) { - app.currentPage.hide(animation); - } - // we call render, but if animation is none, we want to tell the view - // to start with the active class already before appending to DOM. - container.append(view.render(animation === 'none').el); - view.show(animation); - } -}; diff --git a/clientapp/app/models/contact.js b/clientapp/app/models/contact.js deleted file mode 100644 index 5f0fa97..0000000 --- a/clientapp/app/models/contact.js +++ /dev/null @@ -1,116 +0,0 @@ -/* global XMPP, client */ -var StrictModel = require('strictmodel').Model; -var Resources = require('./resources'); -var Messages = require('./messages'); -var Message = require('./message'); -var crypto = XMPP.crypto; - - -module.exports = StrictModel.extend({ - init: function (attrs) { - if (attrs.jid) { - this.cid = attrs.jid; - } - if (!attrs.avatar) { - this.useDefaultAvatar(); - } - - this.resources.bind('add remove reset change', this.resourceChange, this); - }, - type: 'contact', - props: { - jid: ['string', true], - name: ['string', true, ''], - subscription: ['string', true, 'none'], - groups: ['array', true, []] - }, - derived: { - displayName: { - deps: ['name', 'jid'], - fn: function () { - if (this.name) { - return this.name; - } - return this.jid; - } - }, - status: { - deps: ['topResourceStatus', 'offlineStatus'], - fn: function () { - if (this.topResourceStatus) { - return this.topResourceStatus; - } - return this.offlineStatus; - } - } - }, - session: { - topResourceStatus: ['string', true, ''], - offlineStatus: ['string', true, ''], - idleSince: 'date', - avatar: 'string', - show: ['string', true, 'offline'], - chatState: ['string', true, 'gone'], - lockedResource: 'string' - }, - collections: { - resources: Resources, - messages: Messages - }, - useDefaultAvatar: function () { - this.avatar = 'https://gravatar.com/avatar/' + crypto.createHash('md5').update(this.jid).digest('hex') + '?s=30&d=mm'; - }, - resourceChange: function () { - // Manually propagate change events for properties that - // depend on the resources collection. - this.resources.sort(); - - var res = this.resources.first(); - if (res) { - this.offlineStatus = ''; - this.topResourceStatus = res.status; - this.show = res.show || 'online'; - this.lockedResource = undefined; - } else { - this.topResourceStatus = ''; - this.show = 'offline'; - } - }, - fetchHistory: function () { - var self = this; - - client.getHistory({ - with: this.jid, - rsm: { - count: 20, - before: true - } - }, function (err, res) { - if (err) return; - - var results = res.mamQuery.results || []; - results.reverse(); - results.forEach(function (result) { - result = result.toJSON(); - var msg = result.mam.forwarded.message; - - if (!msg.delay) { - msg.delay = result.mam.forwarded.delay; - } - - if (msg.replace) { - var original = self.messages.get(msg.replace); - if (original) { - return original.correct(msg); - } - } - - var message = new Message(); - message.cid = msg.id; - delete msg.id; - message.set(msg); - self.messages.add(message); - }); - }); - } -}); diff --git a/clientapp/app/pages/base.js b/clientapp/app/pages/base.js deleted file mode 100644 index cf1d5ca..0000000 --- a/clientapp/app/pages/base.js +++ /dev/null @@ -1,27 +0,0 @@ -/*global app*/ -var BaseView = require('strictview'), - getOrCall = require('helpers/getOrCall'); - - -module.exports = BaseView.extend({ - show: function (animation) { - $('body').scrollTop(0); - // set the class so it comes into view - //this.$el.addClass('active'); - // store reference to current page - app.currentPage = this; - // set the document title - document.title = getOrCall(this, 'title') + ' • Stanza.io'; - // trigger an event to the page model in case we want to respond - this.trigger('pageloaded'); - return this; - }, - hide: function () { - var self = this; - // tell the model we're bailing - this.trigger('pageunloaded'); - // unbind all events bound for this view - this.remove(); - return this; - } -}); diff --git a/clientapp/app/pages/chat.js b/clientapp/app/pages/chat.js deleted file mode 100644 index ef9aec4..0000000 --- a/clientapp/app/pages/chat.js +++ /dev/null @@ -1,31 +0,0 @@ -/*global app*/ -var BasePage = require('pages/base'); -var templates = require('templates'); -var ContactListItem = require('views/contactListItem'); -var ContactListItemResource = require('views/contactListItemResource'); -var Message = require('views/message'); - - -module.exports = BasePage.extend({ - template: templates.pages.chat, - initialize: function (spec) { - this.render(); - }, - imageBindings: { - avatar: 'header .avatar' - }, - contentBindings: { - name: 'header .name' - }, - render: function () { - this.basicRender(); - this.collectomatic(me.contacts, ContactListItem, { - containerEl: this.$('#contactList') - }, {quick: true}); - this.collectomatic(this.model.messages, Message, { - containerEl: this.$('#conversation') - }, {quick: true}); - this.handleBindings(); - return this; - } -}); diff --git a/clientapp/app/pages/main.js b/clientapp/app/pages/main.js deleted file mode 100644 index 6db0b3c..0000000 --- a/clientapp/app/pages/main.js +++ /dev/null @@ -1,20 +0,0 @@ -/*global app*/ -var BasePage = require('pages/base'); -var templates = require('templates'); -var ContactListItem = require('views/contactListItem'); - - -module.exports = BasePage.extend({ - template: templates.pages.main, - initialize: function (spec) { - this.render(); - }, - render: function () { - this.basicRender(); - this.collectomatic(me.contacts, ContactListItem, { - containerEl: this.$('#contactList') - }, {quick: true}); - this.handleBindings(); - return this; - } -}); diff --git a/clientapp/app/views/main.js b/clientapp/app/views/main.js deleted file mode 100644 index 9eb5c67..0000000 --- a/clientapp/app/views/main.js +++ /dev/null @@ -1,21 +0,0 @@ -/*global ui, app*/ -var BasePage = require('pages/base'), - templates = require('templates'); - - -module.exports = BasePage.extend({ - template: templates.layout, - classBindings: { - }, - contentBindings: { - }, - hrefBindings: { - }, - events: { - }, - render: function () { - this.$el.html(this.template()); - this.handleBindings(); - return this; - } -}); diff --git a/clientapp/app/helpers/getOrCall.js b/clientapp/helpers/getOrCall.js similarity index 93% rename from clientapp/app/helpers/getOrCall.js rename to clientapp/helpers/getOrCall.js index 683cbcf..e45e96c 100644 --- a/clientapp/app/helpers/getOrCall.js +++ b/clientapp/helpers/getOrCall.js @@ -1,3 +1,5 @@ +"use strict"; + // get a property that's a function or direct property module.exports = function (obj, propName) { if (obj[propName] instanceof Function) { diff --git a/clientapp/app/helpers/xmppEventHandlers.js b/clientapp/helpers/xmppEventHandlers.js similarity index 81% rename from clientapp/app/helpers/xmppEventHandlers.js rename to clientapp/helpers/xmppEventHandlers.js index c205a58..e3f42a9 100644 --- a/clientapp/app/helpers/xmppEventHandlers.js +++ b/clientapp/helpers/xmppEventHandlers.js @@ -1,11 +1,12 @@ -/* global XMPP */ +/*global XMPP, me, app, client*/ +"use strict"; + var crypto = XMPP.crypto; var _ = require('underscore'); -var imageToDataURI = require('image-to-data-uri'); -var Contact = require('models/contact'); -var Resource = require('models/resource'); -var Message = require('models/message'); +var Contact = require('../models/contact'); +var Resource = require('../models/resource'); +var Message = require('../models/message'); function logScroll() { @@ -62,8 +63,12 @@ module.exports = function (client, app) { client.getRoster(function (err, resp) { resp = resp.toJSON(); + + localStorage.rosterVersion = resp.roster.ver; + _.each(resp.roster.items, function (item) { - me.contacts.add(item); + console.log(item); + me.setContact(item, true); }); client.updateCaps(); @@ -75,22 +80,22 @@ module.exports = function (client, app) { }); client.on('roster:update', function (iq) { - var items = iq.toJSON().roster.items; + iq = iq.toJSON(); + var items = iq.roster.items; + + localStorage.rosterVersion = iq.roster.ver; + _.each(items, function (item) { var contact = me.getContact(item.jid); if (item.subscription === 'remove') { if (contact) { - me.contacts.remove(contact); + me.removeContact(contact); } return; } - if (contact) { - contact.set(item); - } else { - me.contacts.add(item); - } + me.setContact(item, false); }); }); @@ -132,16 +137,13 @@ module.exports = function (client, app) { client.on('avatar', function (info) { var contact = me.getContact(info.jid); if (contact) { + var id = ''; + var type = 'image/png'; if (info.avatars.length > 0) { - client.getAvatar(info.jid, info.avatars[0].id, function (err, resp) { - if (err) return; - resp = resp.toJSON(); - var avatar = resp.pubsub.retrieve.item.avatarData; - contact.avatar = 'data:' + info.avatars[0].type + ';base64,' + avatar; - }); - } else { - contact.useDefaultAvatar(); + id = info.avatars[0].id; + type = info.avatars[0].type || 'image/png'; } + contact.setAvatar(id, type); } }); @@ -161,8 +163,16 @@ module.exports = function (client, app) { if (contact && !msg.replace) { var message = new Message(); message.cid = msg.id; - delete msg.id; message.set(msg); + + if (msg.archived) { + msg.archived.forEach(function (archived) { + if (me.isMe(archived.by)) { + message.id = archived.id; + } + }); + } + contact.messages.add(message); if (!contact.lockedResource) { contact.lockedResource = msg.from; @@ -216,16 +226,4 @@ module.exports = function (client, app) { client.emit('message', msg); }); - - client.on('message:sent', function (msg) { - var contact = me.getContact(msg.to); - msg = msg.toJSON(); - if (contact && msg.body) { - var message = new Message(); - message.cid = msg.id; - delete msg.id; - message.set(msg); - contact.messages.add(message); - } - }); }; diff --git a/clientapp/libraries/IndexedDBShim.min.js b/clientapp/libraries/IndexedDBShim.min.js new file mode 100644 index 0000000..f5a999e --- /dev/null +++ b/clientapp/libraries/IndexedDBShim.min.js @@ -0,0 +1,2 @@ +/*! IndexedDBShim - v0.1.2 - 2013-06-12 */ +var idbModules={};(function(e){function t(e,t,n,o){n.target=t,"function"==typeof t[e]&&t[e].apply(t,[n]),"function"==typeof o&&o()}function n(t,n,o){var i=new DOMException.constructor(0,n);throw i.name=t,i.message=n,i.stack=arguments.callee.caller,e.DEBUG&&console.log(t,n,o,i),i}var o=function(){this.length=0,this._items=[],Object.defineProperty&&Object.defineProperty(this,"_items",{enumerable:!1})};if(o.prototype={contains:function(e){return-1!==this._items.indexOf(e)},item:function(e){return this._items[e]},indexOf:function(e){return this._items.indexOf(e)},push:function(e){this._items.push(e),this.length+=1;for(var t=0;this._items.length>t;t++)this[t]=this._items[t]},splice:function(){this._items.splice.apply(this._items,arguments),this.length=this._items.length;for(var e in this)e===parseInt(e,10)+""&&delete this[e];for(e=0;this._items.length>e;e++)this[e]=this._items[e]}},Object.defineProperty)for(var i in{indexOf:!1,push:!1,splice:!1})Object.defineProperty(o.prototype,i,{enumerable:!1});e.util={throwDOMException:n,callback:t,quote:function(e){return"'"+e+"'"},StringList:o}})(idbModules),function(e){var t=function(){return{encode:function(e){return JSON.stringify(e)},decode:function(e){return JSON.parse(e)}}}();e.Sca=t}(idbModules),function(e){var t=["","number","string","boolean","object","undefined"],n=function(){return{encode:function(e){return t.indexOf(typeof e)+"-"+JSON.stringify(e)},decode:function(e){return e===void 0?void 0:JSON.parse(e.substring(2))}}},o={number:n("number"),"boolean":n(),object:n(),string:{encode:function(e){return t.indexOf("string")+"-"+e},decode:function(e){return""+e.substring(2)}},undefined:{encode:function(){return t.indexOf("undefined")+"-undefined"},decode:function(){return void 0}}},i=function(){return{encode:function(e){return o[typeof e].encode(e)},decode:function(e){return o[t[e.substring(0,1)]].decode(e)}}}();e.Key=i}(idbModules),function(e){var t=function(e,t){return{type:e,debug:t,bubbles:!1,cancelable:!1,eventPhase:0,timeStamp:new Date}};e.Event=t}(idbModules),function(e){var t=function(){this.onsuccess=this.onerror=this.result=this.error=this.source=this.transaction=null,this.readyState="pending"},n=function(){this.onblocked=this.onupgradeneeded=null};n.prototype=t,e.IDBRequest=t,e.IDBOpenRequest=n}(idbModules),function(e,t){var n=function(e,t,n,o){this.lower=e,this.upper=t,this.lowerOpen=n,this.upperOpen=o};n.only=function(e){return new n(e,e,!0,!0)},n.lowerBound=function(e,o){return new n(e,t,o,t)},n.upperBound=function(e){return new n(t,e,t,open)},n.bound=function(e,t,o,i){return new n(e,t,o,i)},e.IDBKeyRange=n}(idbModules),function(e,t){function n(n,o,i,r,s,a){this.__range=n,this.source=this.__idbObjectStore=i,this.__req=r,this.key=t,this.direction=o,this.__keyColumnName=s,this.__valueColumnName=a,this.source.transaction.__active||e.util.throwDOMException("TransactionInactiveError - The transaction this IDBObjectStore belongs to is not active."),this.__offset=-1,this.__lastKeyContinued=t,this["continue"]()}n.prototype.__find=function(n,o,i,r){var s=this,a=["SELECT * FROM ",e.util.quote(s.__idbObjectStore.name)],u=[];a.push("WHERE ",s.__keyColumnName," NOT NULL"),s.__range&&(s.__range.lower||s.__range.upper)&&(a.push("AND"),s.__range.lower&&(a.push(s.__keyColumnName+(s.__range.lowerOpen?" >":" >= ")+" ?"),u.push(e.Key.encode(s.__range.lower))),s.__range.lower&&s.__range.upper&&a.push("AND"),s.__range.upper&&(a.push(s.__keyColumnName+(s.__range.upperOpen?" < ":" <= ")+" ?"),u.push(e.Key.encode(s.__range.upper)))),n!==t&&(s.__lastKeyContinued=n,s.__offset=0),s.__lastKeyContinued!==t&&(a.push("AND "+s.__keyColumnName+" >= ?"),u.push(e.Key.encode(s.__lastKeyContinued))),a.push("ORDER BY ",s.__keyColumnName),a.push("LIMIT 1 OFFSET "+s.__offset),e.DEBUG&&console.log(a.join(" "),u),o.executeSql(a.join(" "),u,function(n,o){if(1===o.rows.length){var r=e.Key.decode(o.rows.item(0)[s.__keyColumnName]),a="value"===s.__valueColumnName?e.Sca.decode(o.rows.item(0)[s.__valueColumnName]):e.Key.decode(o.rows.item(0)[s.__valueColumnName]);i(r,a)}else e.DEBUG&&console.log("Reached end of cursors"),i(t,t)},function(t,n){e.DEBUG&&console.log("Could not execute Cursor.continue"),r(n)})},n.prototype["continue"]=function(e){var n=this;this.__idbObjectStore.transaction.__addToTransactionQueue(function(o,i,r,s){n.__offset++,n.__find(e,o,function(e,o){n.key=e,n.value=o,r(n.key!==t?n:t,n.__req)},function(e){s(e)})})},n.prototype.advance=function(n){0>=n&&e.util.throwDOMException("Type Error - Count is invalid - 0 or negative",n);var o=this;this.__idbObjectStore.transaction.__addToTransactionQueue(function(e,i,r,s){o.__offset+=n,o.__find(t,e,function(e,n){o.key=e,o.value=n,r(o.key!==t?o:t,o.__req)},function(e){s(e)})})},n.prototype.update=function(n){var o=this;return this.__idbObjectStore.transaction.__addToTransactionQueue(function(i,r,s,a){o.__find(t,i,function(t){var r="UPDATE "+e.util.quote(o.__idbObjectStore.name)+" SET value = ? WHERE key = ?";e.DEBUG&&console.log(r,n,t),i.executeSql(r,[e.Sca.encode(n),e.Key.encode(t)],function(e,n){1===n.rowsAffected?s(t):a("No rowns with key found"+t)},function(e,t){a(t)})},function(e){a(e)})})},n.prototype["delete"]=function(){var n=this;return this.__idbObjectStore.transaction.__addToTransactionQueue(function(o,i,r,s){n.__find(t,o,function(i){var a="DELETE FROM "+e.util.quote(n.__idbObjectStore.name)+" WHERE key = ?";e.DEBUG&&console.log(a,i),o.executeSql(a,[e.Key.encode(i)],function(e,n){1===n.rowsAffected?r(t):s("No rowns with key found"+i)},function(e,t){s(t)})},function(e){s(e)})})},e.IDBCursor=n}(idbModules),function(idbModules,undefined){function IDBIndex(e,t){this.indexName=this.name=e,this.__idbObjectStore=this.objectStore=this.source=t;var n=t.__storeProps&&t.__storeProps.indexList;n&&(n=JSON.parse(n)),this.keyPath=n&&n[e]&&n[e].keyPath||e,["multiEntry","unique"].forEach(function(t){this[t]=!!(n&&n[e]&&n[e].optionalParams&&n[e].optionalParams[t])},this)}IDBIndex.prototype.__createIndex=function(indexName,keyPath,optionalParameters){var me=this,transaction=me.__idbObjectStore.transaction;transaction.__addToTransactionQueue(function(tx,args,success,failure){me.__idbObjectStore.__getStoreProps(tx,function(){function error(){idbModules.util.throwDOMException(0,"Could not create new index",arguments)}2!==transaction.mode&&idbModules.util.throwDOMException(0,"Invalid State error, not a version transaction",me.transaction);var idxList=JSON.parse(me.__idbObjectStore.__storeProps.indexList);idxList[indexName]!==undefined&&idbModules.util.throwDOMException(0,"Index already exists on store",idxList);var columnName=indexName;idxList[indexName]={columnName:columnName,keyPath:keyPath,optionalParams:optionalParameters},me.__idbObjectStore.__storeProps.indexList=JSON.stringify(idxList);var sql=["ALTER TABLE",idbModules.util.quote(me.__idbObjectStore.name),"ADD",columnName,"BLOB"].join(" ");idbModules.DEBUG&&console.log(sql),tx.executeSql(sql,[],function(tx,data){tx.executeSql("SELECT * FROM "+idbModules.util.quote(me.__idbObjectStore.name),[],function(tx,data){(function initIndexForRow(i){if(data.rows.length>i)try{var value=idbModules.Sca.decode(data.rows.item(i).value),indexKey=eval("value['"+keyPath+"']");tx.executeSql("UPDATE "+idbModules.util.quote(me.__idbObjectStore.name)+" set "+columnName+" = ? where key = ?",[idbModules.Key.encode(indexKey),data.rows.item(i).key],function(){initIndexForRow(i+1)},error)}catch(e){initIndexForRow(i+1)}else idbModules.DEBUG&&console.log("Updating the indexes in table",me.__idbObjectStore.__storeProps),tx.executeSql("UPDATE __sys__ set indexList = ? where name = ?",[me.__idbObjectStore.__storeProps.indexList,me.__idbObjectStore.name],function(){me.__idbObjectStore.__setReadyState("createIndex",!0),success(me)},error)})(0)},error)},error)},"createObjectStore")})},IDBIndex.prototype.openCursor=function(e,t){var n=new idbModules.IDBRequest;return new idbModules.IDBCursor(e,t,this.source,n,this.indexName,"value"),n},IDBIndex.prototype.openKeyCursor=function(e,t){var n=new idbModules.IDBRequest;return new idbModules.IDBCursor(e,t,this.source,n,this.indexName,"key"),n},IDBIndex.prototype.__fetchIndexData=function(e,t){var n=this;return n.__idbObjectStore.transaction.__addToTransactionQueue(function(o,i,r,s){var a=["SELECT * FROM ",idbModules.util.quote(n.__idbObjectStore.name)," WHERE",n.indexName,"NOT NULL"],u=[];e!==undefined&&(a.push("AND",n.indexName," = ?"),u.push(idbModules.Key.encode(e))),idbModules.DEBUG&&console.log("Trying to fetch data for Index",a.join(" "),u),o.executeSql(a.join(" "),u,function(e,n){var o;o="count"==typeof t?n.rows.length:0===n.rows.length?undefined:"key"===t?idbModules.Key.decode(n.rows.item(0).key):idbModules.Sca.decode(n.rows.item(0).value),r(o)},s)})},IDBIndex.prototype.get=function(e){return this.__fetchIndexData(e,"value")},IDBIndex.prototype.getKey=function(e){return this.__fetchIndexData(e,"key")},IDBIndex.prototype.count=function(e){return this.__fetchIndexData(e,"count")},idbModules.IDBIndex=IDBIndex}(idbModules),function(idbModules){var IDBObjectStore=function(e,t,n){this.name=e,this.transaction=t,this.__ready={},this.__setReadyState("createObjectStore",n===void 0?!0:n),this.indexNames=new idbModules.util.StringList};IDBObjectStore.prototype.__setReadyState=function(e,t){this.__ready[e]=t},IDBObjectStore.prototype.__waitForReady=function(e,t){var n=!0;if(t!==void 0)n=this.__ready[t]===void 0?!0:this.__ready[t];else for(var o in this.__ready)this.__ready[o]||(n=!1);if(n)e();else{idbModules.DEBUG&&console.log("Waiting for to be ready",t);var i=this;window.setTimeout(function(){i.__waitForReady(e,t)},100)}},IDBObjectStore.prototype.__getStoreProps=function(e,t,n){var o=this;this.__waitForReady(function(){o.__storeProps?(idbModules.DEBUG&&console.log("Store properties - cached",o.__storeProps),t(o.__storeProps)):e.executeSql("SELECT * FROM __sys__ where name = ?",[o.name],function(e,n){1!==n.rows.length?t():(o.__storeProps={name:n.rows.item(0).name,indexList:n.rows.item(0).indexList,autoInc:n.rows.item(0).autoInc,keyPath:n.rows.item(0).keyPath},idbModules.DEBUG&&console.log("Store properties",o.__storeProps),t(o.__storeProps))},function(){t()})},n)},IDBObjectStore.prototype.__deriveKey=function(tx,value,key,callback){function getNextAutoIncKey(){tx.executeSql("SELECT * FROM sqlite_sequence where name like ?",[me.name],function(e,t){1!==t.rows.length?callback(0):callback(t.rows.item(0).seq)},function(e,t){idbModules.util.throwDOMException(0,"Data Error - Could not get the auto increment value for key",t)})}var me=this;me.__getStoreProps(tx,function(props){if(props||idbModules.util.throwDOMException(0,"Data Error - Could not locate defination for this table",props),props.keyPath)if(key!==void 0&&idbModules.util.throwDOMException(0,"Data Error - The object store uses in-line keys and the key parameter was provided",props),value)try{var primaryKey=eval("value['"+props.keyPath+"']");primaryKey?callback(primaryKey):"true"===props.autoInc?getNextAutoIncKey():idbModules.util.throwDOMException(0,"Data Error - Could not eval key from keyPath")}catch(e){idbModules.util.throwDOMException(0,"Data Error - Could not eval key from keyPath",e)}else idbModules.util.throwDOMException(0,"Data Error - KeyPath was specified, but value was not");else key!==void 0?callback(key):"false"===props.autoInc?idbModules.util.throwDOMException(0,"Data Error - The object store uses out-of-line keys and has no key generator and the key parameter was not provided. ",props):getNextAutoIncKey()})},IDBObjectStore.prototype.__insertData=function(tx,value,primaryKey,success,error){var paramMap={};primaryKey!==void 0&&(paramMap.key=idbModules.Key.encode(primaryKey));var indexes=JSON.parse(this.__storeProps.indexList);for(var key in indexes)try{paramMap[indexes[key].columnName]=idbModules.Key.encode(eval("value['"+indexes[key].keyPath+"']"))}catch(e){error(e)}var sqlStart=["INSERT INTO ",idbModules.util.quote(this.name),"("],sqlEnd=[" VALUES ("],sqlValues=[];for(key in paramMap)sqlStart.push(key+","),sqlEnd.push("?,"),sqlValues.push(paramMap[key]);sqlStart.push("value )"),sqlEnd.push("?)"),sqlValues.push(idbModules.Sca.encode(value));var sql=sqlStart.join(" ")+sqlEnd.join(" ");idbModules.DEBUG&&console.log("SQL for adding",sql,sqlValues),tx.executeSql(sql,sqlValues,function(){success(primaryKey)},function(e,t){error(t)})},IDBObjectStore.prototype.add=function(e,t){var n=this;return n.transaction.__addToTransactionQueue(function(o,i,r,s){n.__deriveKey(o,e,t,function(t){n.__insertData(o,e,t,r,s)})})},IDBObjectStore.prototype.put=function(e,t){var n=this;return n.transaction.__addToTransactionQueue(function(o,i,r,s){n.__deriveKey(o,e,t,function(t){var i="DELETE FROM "+idbModules.util.quote(n.name)+" where key = ?";o.executeSql(i,[idbModules.Key.encode(t)],function(o,i){idbModules.DEBUG&&console.log("Did the row with the",t,"exist? ",i.rowsAffected),n.__insertData(o,e,t,r,s)},function(e,t){s(t)})})})},IDBObjectStore.prototype.get=function(e){var t=this;return t.transaction.__addToTransactionQueue(function(n,o,i,r){t.__waitForReady(function(){var o=idbModules.Key.encode(e);idbModules.DEBUG&&console.log("Fetching",t.name,o),n.executeSql("SELECT * FROM "+idbModules.util.quote(t.name)+" where key = ?",[o],function(e,t){idbModules.DEBUG&&console.log("Fetched data",t);try{if(0===t.rows.length)return i();i(idbModules.Sca.decode(t.rows.item(0).value))}catch(n){idbModules.DEBUG&&console.log(n),i(void 0)}},function(e,t){r(t)})})})},IDBObjectStore.prototype["delete"]=function(e){var t=this;return t.transaction.__addToTransactionQueue(function(n,o,i,r){t.__waitForReady(function(){var o=idbModules.Key.encode(e);idbModules.DEBUG&&console.log("Fetching",t.name,o),n.executeSql("DELETE FROM "+idbModules.util.quote(t.name)+" where key = ?",[o],function(e,t){idbModules.DEBUG&&console.log("Deleted from database",t.rowsAffected),i()},function(e,t){r(t)})})})},IDBObjectStore.prototype.clear=function(){var e=this;return e.transaction.__addToTransactionQueue(function(t,n,o,i){e.__waitForReady(function(){t.executeSql("DELETE FROM "+idbModules.util.quote(e.name),[],function(e,t){idbModules.DEBUG&&console.log("Cleared all records from database",t.rowsAffected),o()},function(e,t){i(t)})})})},IDBObjectStore.prototype.count=function(e){var t=this;return t.transaction.__addToTransactionQueue(function(n,o,i,r){t.__waitForReady(function(){var o="SELECT * FROM "+idbModules.util.quote(t.name)+(e!==void 0?" WHERE key = ?":""),s=[];e!==void 0&&s.push(idbModules.Key.encode(e)),n.executeSql(o,s,function(e,t){i(t.rows.length)},function(e,t){r(t)})})})},IDBObjectStore.prototype.openCursor=function(e,t){var n=new idbModules.IDBRequest;return new idbModules.IDBCursor(e,t,this,n,"key","value"),n},IDBObjectStore.prototype.index=function(e){var t=new idbModules.IDBIndex(e,this);return t},IDBObjectStore.prototype.createIndex=function(e,t,n){var o=this;n=n||{},o.__setReadyState("createIndex",!1);var i=new idbModules.IDBIndex(e,o);return o.__waitForReady(function(){i.__createIndex(e,t,n)},"createObjectStore"),o.indexNames.push(e),i},IDBObjectStore.prototype.deleteIndex=function(e){var t=new idbModules.IDBIndex(e,this,!1);return t.__deleteIndex(e),t},idbModules.IDBObjectStore=IDBObjectStore}(idbModules),function(e){var t=0,n=1,o=2,i=function(o,i,r){if("number"==typeof i)this.mode=i,2!==i&&e.DEBUG&&console.log("Mode should be a string, but was specified as ",i);else if("string"==typeof i)switch(i){case"readwrite":this.mode=n;break;case"readonly":this.mode=t;break;default:this.mode=t}this.storeNames="string"==typeof o?[o]:o;for(var s=0;this.storeNames.length>s;s++)r.objectStoreNames.contains(this.storeNames[s])||e.util.throwDOMException(0,"The operation failed because the requested database object could not be found. For example, an object store did not exist but was being opened.",this.storeNames[s]);this.__active=!0,this.__running=!1,this.__requests=[],this.__aborted=!1,this.db=r,this.error=null,this.onabort=this.onerror=this.oncomplete=null};i.prototype.__executeRequests=function(){if(this.__running&&this.mode!==o)return e.DEBUG&&console.log("Looks like the request set is already running",this.mode),void 0;this.__running=!0;var t=this;window.setTimeout(function(){2===t.mode||t.__active||e.util.throwDOMException(0,"A request was placed against a transaction which is currently not active, or which is finished",t.__active),t.db.__db.transaction(function(n){function o(t,n){n&&(s.req=n),s.req.readyState="done",s.req.result=t,delete s.req.error;var o=e.Event("success");e.util.callback("onsuccess",s.req,o),a++,r()}function i(){s.req.readyState="done",s.req.error="DOMError";var t=e.Event("error",arguments);e.util.callback("onerror",s.req,t),a++,r()}function r(){return a>=t.__requests.length?(t.__active=!1,t.__requests=[],void 0):(s=t.__requests[a],s.op(n,s.args,o,i),void 0)}t.__tx=n;var s=null,a=0;try{r()}catch(u){e.DEBUG&&console.log("An exception occured in transaction",arguments),"function"==typeof t.onerror&&t.onerror()}},function(){e.DEBUG&&console.log("An error in transaction",arguments),"function"==typeof t.onerror&&t.onerror()},function(){e.DEBUG&&console.log("Transaction completed",arguments),"function"==typeof t.oncomplete&&t.oncomplete()})},1)},i.prototype.__addToTransactionQueue=function(t,n){this.__active||this.mode===o||e.util.throwDOMException(0,"A request was placed against a transaction which is currently not active, or which is finished.",this.__mode);var i=new e.IDBRequest;return i.source=this.db,this.__requests.push({op:t,args:n,req:i}),this.__executeRequests(),i},i.prototype.objectStore=function(t){return new e.IDBObjectStore(t,this)},i.prototype.abort=function(){!this.__active&&e.util.throwDOMException(0,"A request was placed against a transaction which is currently not active, or which is finished",this.__active)},i.prototype.READ_ONLY=0,i.prototype.READ_WRITE=1,i.prototype.VERSION_CHANGE=2,e.IDBTransaction=i}(idbModules),function(e){var t=function(t,n,o,i){this.__db=t,this.version=o,this.__storeProperties=i,this.objectStoreNames=new e.util.StringList;for(var r=0;i.rows.length>r;r++)this.objectStoreNames.push(i.rows.item(r).name);this.name=n,this.onabort=this.onerror=this.onversionchange=null};t.prototype.createObjectStore=function(t,n){var o=this;n=n||{},n.keyPath=n.keyPath||null;var i=new e.IDBObjectStore(t,o.__versionTransaction,!1),r=o.__versionTransaction;return r.__addToTransactionQueue(function(r,s,a){function u(){e.util.throwDOMException(0,"Could not create new object store",arguments)}o.__versionTransaction||e.util.throwDOMException(0,"Invalid State error",o.transaction);var c=["CREATE TABLE",e.util.quote(t),"(key BLOB",n.autoIncrement?", inc INTEGER PRIMARY KEY AUTOINCREMENT":"PRIMARY KEY",", value BLOB)"].join(" ");e.DEBUG&&console.log(c),r.executeSql(c,[],function(e){e.executeSql("INSERT INTO __sys__ VALUES (?,?,?,?)",[t,n.keyPath,n.autoIncrement?!0:!1,"{}"],function(){i.__setReadyState("createObjectStore",!0),a(i)},u)},u)}),o.objectStoreNames.push(t),i},t.prototype.deleteObjectStore=function(t){var n=function(){e.util.throwDOMException(0,"Could not delete ObjectStore",arguments)},o=this;!o.objectStoreNames.contains(t)&&n("Object Store does not exist"),o.objectStoreNames.splice(o.objectStoreNames.indexOf(t),1);var i=o.__versionTransaction;i.__addToTransactionQueue(function(){o.__versionTransaction||e.util.throwDOMException(0,"Invalid State error",o.transaction),o.__db.transaction(function(o){o.executeSql("SELECT * FROM __sys__ where name = ?",[t],function(o,i){i.rows.length>0&&o.executeSql("DROP TABLE "+e.util.quote(t),[],function(){o.executeSql("DELETE FROM __sys__ WHERE name = ?",[t],function(){},n)},n)})})})},t.prototype.close=function(){},t.prototype.transaction=function(t,n){var o=new e.IDBTransaction(t,n||1,this);return o},e.IDBDatabase=t}(idbModules),function(e){var t=4194304;if(window.openDatabase){var n=window.openDatabase("__sysdb__",1,"System Database",t);n.transaction(function(t){t.executeSql("SELECT * FROM dbVersions",[],function(){},function(){n.transaction(function(t){t.executeSql("CREATE TABLE IF NOT EXISTS dbVersions (name VARCHAR(255), version INT);",[],function(){},function(){e.util.throwDOMException("Could not create table __sysdb__ to save DB versions")})})})},function(){e.DEBUG&&console.log("Error in sysdb transaction - when selecting from dbVersions",arguments)});var o={open:function(o,i){function r(){if(!u){var t=e.Event("error",arguments);a.readyState="done",a.error="DOMError",e.util.callback("onerror",a,t),u=!0}}function s(s){var u=window.openDatabase(o,1,o,t);a.readyState="done",i===void 0&&(i=s||1),(0>=i||s>i)&&e.util.throwDOMException(0,"An attempt was made to open a database using a lower version than the existing version.",i),u.transaction(function(t){t.executeSql("CREATE TABLE IF NOT EXISTS __sys__ (name VARCHAR(255), keyPath VARCHAR(255), autoInc BOOLEAN, indexList BLOB)",[],function(){t.executeSql("SELECT * FROM __sys__",[],function(t,c){var d=e.Event("success");a.source=a.result=new e.IDBDatabase(u,o,i,c),i>s?n.transaction(function(t){t.executeSql("UPDATE dbVersions set version = ? where name = ?",[i,o],function(){var t=e.Event("upgradeneeded");t.oldVersion=s,t.newVersion=i,a.transaction=a.result.__versionTransaction=new e.IDBTransaction([],2,a.source),e.util.callback("onupgradeneeded",a,t,function(){var t=e.Event("success");e.util.callback("onsuccess",a,t)})},r)},r):e.util.callback("onsuccess",a,d)},r)},r)},r)}var a=new e.IDBOpenRequest,u=!1;return n.transaction(function(e){e.executeSql("SELECT * FROM dbVersions where name = ?",[o],function(e,t){0===t.rows.length?e.executeSql("INSERT INTO dbVersions VALUES (?,?)",[o,i||1],function(){s(0)},r):s(t.rows.item(0).version)},r)},r),a},deleteDatabase:function(o){function i(t){if(!a){s.readyState="done",s.error="DOMError";var n=e.Event("error");n.message=t,n.debug=arguments,e.util.callback("onerror",s,n),a=!0}}function r(){n.transaction(function(t){t.executeSql("DELETE FROM dbVersions where name = ? ",[o],function(){s.result=void 0;var t=e.Event("success");t.newVersion=null,t.oldVersion=u,e.util.callback("onsuccess",s,t)},i)},i)}var s=new e.IDBOpenRequest,a=!1,u=null;return n.transaction(function(n){n.executeSql("SELECT * FROM dbVersions where name = ?",[o],function(n,a){if(0===a.rows.length){s.result=void 0;var c=e.Event("success");return c.newVersion=null,c.oldVersion=u,e.util.callback("onsuccess",s,c),void 0}u=a.rows.item(0).version;var d=window.openDatabase(o,1,o,t);d.transaction(function(t){t.executeSql("SELECT * FROM __sys__",[],function(t,n){var o=n.rows;(function s(n){n>=o.length?t.executeSql("DROP TABLE __sys__",[],function(){r()},i):t.executeSql("DROP TABLE "+e.util.quote(o.item(n).name),[],function(){s(n+1)},function(){s(n+1)})})(0)},function(){r()})},i)})},i),s},cmp:function(t,n){return e.Key.encode(t)>e.Key.encode(n)?1:t===n?0:-1}};e.shimIndexedDB=o}}(idbModules),function(e,t){e.openDatabase!==void 0&&(e.shimIndexedDB=t.shimIndexedDB,e.shimIndexedDB&&(e.shimIndexedDB.__useShim=function(){e.indexedDB=t.shimIndexedDB,e.IDBDatabase=t.IDBDatabase,e.IDBTransaction=t.IDBTransaction,e.IDBCursor=t.IDBCursor,e.IDBKeyRange=t.IDBKeyRange},e.shimIndexedDB.__debug=function(e){t.DEBUG=e})),e.indexedDB=e.indexedDB||e.webkitIndexedDB||e.mozIndexedDB||e.oIndexedDB||e.msIndexedDB,e.indexedDB===void 0&&e.openDatabase!==void 0?e.shimIndexedDB.__useShim():(e.IDBDatabase=e.IDBDatabase||e.webkitIDBDatabase,e.IDBTransaction=e.IDBTransaction||e.webkitIDBTransaction,e.IDBCursor=e.IDBCursor||e.webkitIDBCursor,e.IDBKeyRange=e.IDBKeyRange||e.webkitIDBKeyRange,e.IDBTransaction||(e.IDBTransaction={}),e.IDBTransaction.READ_ONLY=e.IDBTransaction.READ_ONLY||"readonly",e.IDBTransaction.READ_WRITE=e.IDBTransaction.READ_WRITE||"readwrite")}(window,idbModules); diff --git a/clientapp/libraries/stanza.io.js b/clientapp/libraries/stanza.io.js index ea78b5f..a911814 100644 --- a/clientapp/libraries/stanza.io.js +++ b/clientapp/libraries/stanza.io.js @@ -422,7 +422,10 @@ Client.prototype.sendMessage = function (data) { } var message = new Message(data); - this.send(new Message(data)); + this.emit('message:sent', message); + this.send(message); + + return data.id; }; Client.prototype.sendPresence = function (data) { @@ -431,6 +434,8 @@ Client.prototype.sendPresence = function (data) { data.id = this.nextId(); } this.send(new Presence(data)); + + return data.id; }; Client.prototype.sendIq = function (data, cb) { @@ -449,6 +454,8 @@ Client.prototype.sendIq = function (data, cb) { }); } this.send(new Iq(data)); + + return data.id; }; Client.prototype.getRoster = function (cb) { @@ -2811,34 +2818,6 @@ Result.prototype = { }; -function Archived(data, xml) { - return stanza.init(this, xml, data); -} -Archived.prototype = { - constructor: { - value: Result - }, - NS: 'urn:xmpp:mam:tmp', - EL: 'archived', - _name: 'archived', - _eventname: 'mam:archived', - toString: stanza.toString, - toJSON: stanza.toJSON, - get by() { - return stanza.getAttribute(this.xml, 'by'); - }, - set by(value) { - stanza.setAttribute(this.xml, 'by', value); - }, - get id() { - return stanza.getAttribute(this.xml, 'id'); - }, - set id(value) { - stanza.setAttribute(this.xml, 'id', value); - } -}; - - function Prefs(data, xml) { return stanza.init(this, xml, data); } @@ -2904,10 +2883,37 @@ Prefs.prototype = { stanza.extend(Iq, MAMQuery); stanza.extend(Iq, Prefs); stanza.extend(Message, Result); -stanza.extend(Message, Archived); stanza.extend(Result, Forwarded); stanza.extend(MAMQuery, RSM); + +Message.prototype.__defineGetter__('archived', function () { + var self = this; + + var archives = stanza.find(this.xml, 'urn:xmpp:mam:tmp', 'archived'); + + var results = []; + archives.forEach(function (archive) { + results.push({ + by: stanza.getAttribute(archive, 'by'), + id: stanza.getAttribute(archive, 'id') + }); + }); + + return results; +}); +Message.prototype.__defineSetter__('archived', function (value) { + var self = this; + + value.forEach(function (val) { + var archive = document.createElementNS('urn:xmpp:mam:tmp', 'archived'); + stanza.setAttribute(archive, 'by', val.by); + stanza.setAttribute(archive, 'id', val.id); + self.xml.appendChild(archive); + }); +}); + + exports.MAMQuery = MAMQuery; exports.Result = Result; diff --git a/clientapp/app/models/baseCollection.js b/clientapp/models/baseCollection.js similarity index 98% rename from clientapp/app/models/baseCollection.js rename to clientapp/models/baseCollection.js index 7a211cf..19fe658 100644 --- a/clientapp/app/models/baseCollection.js +++ b/clientapp/models/baseCollection.js @@ -1,3 +1,5 @@ +"use strict"; + // our base collection var Backbone = require('backbone'); diff --git a/clientapp/models/contact.js b/clientapp/models/contact.js new file mode 100644 index 0000000..dbce7f2 --- /dev/null +++ b/clientapp/models/contact.js @@ -0,0 +1,179 @@ +/*global XMPP, app, me, client*/ +"use strict"; + +var async = require('async'); +var StrictModel = require('strictmodel').Model; +var imageToDataURI = require('image-to-data-uri'); +var Resources = require('./resources'); +var Messages = require('./messages'); +var Message = require('./message'); +var crypto = XMPP.crypto; + + +module.exports = StrictModel.extend({ + initialize: function (attrs) { + if (attrs.jid) { + this.cid = attrs.jid; + } + + this.setAvatar(attrs.avatarID); + + this.resources.bind('add remove reset change', this.resourceChange, this); + }, + type: 'contact', + props: { + jid: ['string', true], + name: ['string', true, ''], + subscription: ['string', true, 'none'], + groups: ['array', true, []], + avatarID: ['string', true, ''] + }, + derived: { + displayName: { + deps: ['name', 'jid'], + fn: function () { + if (this.name) { + return this.name; + } + return this.jid; + } + }, + status: { + deps: ['topResourceStatus', 'offlineStatus'], + fn: function () { + if (this.topResourceStatus) { + return this.topResourceStatus; + } + return this.offlineStatus; + } + }, + lockedJID: { + deps: ['jid', 'lockedResource'], + fn: function () { + if (this.lockedResource) { + return this.jid + '/' + this.lockedResource; + } + return this.jid; + } + } + }, + session: { + topResourceStatus: ['string', true, ''], + offlineStatus: ['string', true, ''], + idleSince: 'date', + avatar: 'string', + show: ['string', true, 'offline'], + chatState: ['string', true, 'gone'], + lockedResource: 'string', + lastSentMessage: 'object' + }, + collections: { + resources: Resources, + messages: Messages + }, + setAvatar: function (id, type) { + var self = this; + + + if (!id) { + var gID = crypto.createHash('md5').update(this.jid).digest('hex'); + self.avatar = 'https://gravatar.com/avatar/' + gID + '?s=30&d=mm'; + return; + } + + app.storage.avatars.get(id, function (err, avatar) { + if (err) { + if (!type) { + // We can't find the ID, and we don't know the type, so fallback. + var gID = crypto.createHash('md5').update(self.jid).digest('hex'); + self.avatar = 'https://gravatar.com/avatar/' + gID + '?s=30&d=mm'; + return; + } + app.whenConnected(function () { + client.getAvatar(self.jid, id, function (err, resp) { + if (err) return; + resp = resp.toJSON(); + var avatarData = resp.pubsub.retrieve.item.avatarData; + var dataURI = 'data:' + type + ';base64,' + avatarData; + app.storage.avatars.add({id: id, uri: dataURI}); + self.set({ + avatar: dataURI, + avatarID: id + }); + self.save(); + }); + }); + } else { + self.set({ + avatar: avatar.uri, + avatarID: avatar.id + }); + self.save(); + } + }); + }, + resourceChange: function () { + // Manually propagate change events for properties that + // depend on the resources collection. + this.resources.sort(); + + var res = this.resources.first(); + if (res) { + this.offlineStatus = ''; + this.topResourceStatus = res.status; + this.show = res.show || 'online'; + this.lockedResource = undefined; + } else { + this.topResourceStatus = ''; + this.show = 'offline'; + } + }, + fetchHistory: function () { + var self = this; + + app.whenConnected(function () { + client.getHistory({ + with: self.jid, + rsm: { + count: 20, + before: true + } + }, function (err, res) { + if (err) return; + + var results = res.mamQuery.results || []; + results.reverse(); + results.forEach(function (result) { + result = result.toJSON(); + var msg = result.mam.forwarded.message; + + if (!msg.delay) { + msg.delay = result.mam.forwarded.delay; + } + + if (msg.replace) { + var original = self.messages.get(msg.replace); + if (original) { + return original.correct(msg); + } + } + + var message = new Message(); + message.cid = msg.id || result.mam.id; + message.set(msg); + self.messages.add(message); + }); + }); + }); + }, + save: function () { + var data = { + jid: this.jid, + name: this.name, + groups: this.groups, + subscription: this.subscription, + avatarID: this.avatarID + }; + app.storage.roster.add(data); + } +}); diff --git a/clientapp/app/models/contacts.js b/clientapp/models/contacts.js similarity index 75% rename from clientapp/app/models/contacts.js rename to clientapp/models/contacts.js index b38d16b..842e174 100644 --- a/clientapp/app/models/contacts.js +++ b/clientapp/models/contacts.js @@ -1,3 +1,7 @@ +/*global app*/ +"use strict"; + +var async = require('async'); var BaseCollection = require('./baseCollection'); var Contact = require('./contact'); @@ -40,13 +44,20 @@ module.exports = BaseCollection.extend({ } }, initialize: function (model, options) { + var self = this; this.bind('change', this.orderChange, this); - this.bind('add', this.fetchHistory, this); + + app.storage.roster.getAll(function (err, contacts) { + if (err) return; + + contacts.forEach(function (contact) { + contact = new Contact(contact); + contact.save(); + self.add(contact); + }); + }); }, orderChange: function () { this.sort(); - }, - fetchHistory: function (contact) { - contact.fetchHistory(); } }); diff --git a/clientapp/app/models/me.js b/clientapp/models/me.js similarity index 69% rename from clientapp/app/models/me.js rename to clientapp/models/me.js index db9cd5d..412f37f 100644 --- a/clientapp/app/models/me.js +++ b/clientapp/models/me.js @@ -1,5 +1,9 @@ +/*global app*/ +"use strict"; + var StrictModel = require('strictmodel'); var Contacts = require('./contacts'); +var Contact = require('./contact'); module.exports = StrictModel.Model.extend({ @@ -34,6 +38,21 @@ module.exports = StrictModel.Model.extend({ } return this.contacts.get(jid); }, + setContact: function (data, create) { + var contact = this.getContact(data.jid); + if (contact) { + contact.set(data); + contact.save(); + } else if (create) { + contact = new Contact(data); + contact.save(); + this.contacts.add(contact); + } + }, + removeContact: function (jid) { + this.contacts.remove(jid); + app.storage.roster.remove(jid); + }, isMe: function (jid) { var hasResource = jid.indexOf('/') > 0; if (hasResource) { diff --git a/clientapp/app/models/message.js b/clientapp/models/message.js similarity index 78% rename from clientapp/app/models/message.js rename to clientapp/models/message.js index b3a616d..b45577a 100644 --- a/clientapp/app/models/message.js +++ b/clientapp/models/message.js @@ -1,17 +1,24 @@ +/*global me*/ +"use strict"; + var StrictModel = require('strictmodel').Model; module.exports = StrictModel.extend({ - init: function (attrs) { + initialize: function (attrs) { + console.log(attrs); this._created = Date.now(); }, type: 'message', + idDefinition: { + type: 'string' + }, props: { to: ['string', true, ''], from: ['string', true, ''], body: ['string', true, ''], type: ['string', true, 'normal'], - acked: ['bool', true, false], + acked: ['bool', true, false] }, derived: { mine: { @@ -38,7 +45,10 @@ module.exports = StrictModel.extend({ formattedTime: { deps: ['created'], fn: function () { - return this.created.format('{MM}/{dd} {h}:{mm}{t}'); + if (this.created) { + return this.created.format('{MM}/{dd} {h}:{mm}{t}'); + } + return undefined; } } }, @@ -46,7 +56,7 @@ module.exports = StrictModel.extend({ _created: 'date', receiptReceived: ['bool', true, false], edited: ['bool', true, false], - delay: 'object', + delay: 'object' }, correct: function (msg) { if (this.from !== msg.from) return; diff --git a/clientapp/app/models/messages.js b/clientapp/models/messages.js similarity index 96% rename from clientapp/app/models/messages.js rename to clientapp/models/messages.js index fb5efb9..8975916 100644 --- a/clientapp/app/models/messages.js +++ b/clientapp/models/messages.js @@ -1,3 +1,5 @@ +"use strict"; + var BaseCollection = require('./baseCollection'); var Message = require('./message'); diff --git a/clientapp/app/models/resource.js b/clientapp/models/resource.js similarity index 86% rename from clientapp/app/models/resource.js rename to clientapp/models/resource.js index a64f9c5..1d77f99 100644 --- a/clientapp/app/models/resource.js +++ b/clientapp/models/resource.js @@ -1,8 +1,10 @@ +"use strict"; + var StrictModel = require('strictmodel').Model; module.exports = StrictModel.extend({ - init: function () {}, + initialize: function () {}, type: 'resource', session: { jid: ['string', true], diff --git a/clientapp/app/models/resources.js b/clientapp/models/resources.js similarity index 98% rename from clientapp/app/models/resources.js rename to clientapp/models/resources.js index 66de0da..bb4c456 100644 --- a/clientapp/app/models/resources.js +++ b/clientapp/models/resources.js @@ -1,3 +1,5 @@ +"use strict"; + var BaseCollection = require('./baseCollection'); var Resource = require('./resource'); diff --git a/clientapp/modules/andlog.js b/clientapp/modules/andlog.js deleted file mode 100644 index 2c4da9e..0000000 --- a/clientapp/modules/andlog.js +++ /dev/null @@ -1,28 +0,0 @@ -// follow @HenrikJoreteg and @andyet if you like this ;) -(function (window) { - var ls = window.localStorage, - out = {}, - inNode = typeof process !== 'undefined'; - - if (inNode) { - module.exports = console; - return; - } - - if (ls && ls.debug && window.console) { - out = window.console; - } else { - var methods = "assert,count,debug,dir,dirxml,error,exception,group,groupCollapsed,groupEnd,info,log,markTimeline,profile,profileEnd,time,timeEnd,trace,warn".split(","), - l = methods.length, - fn = function () {}; - - while (l--) { - out[methods[l]] = fn; - } - } - if (typeof exports !== 'undefined') { - module.exports = out; - } else { - window.console = out; - } -})(this); \ No newline at end of file diff --git a/clientapp/modules/backbone.js b/clientapp/modules/backbone.js deleted file mode 100644 index 3512d42..0000000 --- a/clientapp/modules/backbone.js +++ /dev/null @@ -1,1571 +0,0 @@ -// Backbone.js 1.0.0 - -// (c) 2010-2013 Jeremy Ashkenas, DocumentCloud Inc. -// Backbone may be freely distributed under the MIT license. -// For all details and documentation: -// http://backbonejs.org - -(function(){ - - // Initial Setup - // ------------- - - // Save a reference to the global object (`window` in the browser, `exports` - // on the server). - var root = this; - - // Save the previous value of the `Backbone` variable, so that it can be - // restored later on, if `noConflict` is used. - var previousBackbone = root.Backbone; - - // Create local references to array methods we'll want to use later. - var array = []; - var push = array.push; - var slice = array.slice; - var splice = array.splice; - - // The top-level namespace. All public Backbone classes and modules will - // be attached to this. Exported for both the browser and the server. - var Backbone; - if (typeof exports !== 'undefined') { - Backbone = exports; - } else { - Backbone = root.Backbone = {}; - } - - // Current version of the library. Keep in sync with `package.json`. - Backbone.VERSION = '1.0.0'; - - // Require Underscore, if we're on the server, and it's not already present. - var _ = root._; - if (!_ && (typeof require !== 'undefined')) _ = require('underscore'); - - // For Backbone's purposes, jQuery, Zepto, Ender, or My Library (kidding) owns - // the `$` variable. - Backbone.$ = root.jQuery || root.Zepto || root.ender || root.$; - - // Runs Backbone.js in *noConflict* mode, returning the `Backbone` variable - // to its previous owner. Returns a reference to this Backbone object. - Backbone.noConflict = function() { - root.Backbone = previousBackbone; - return this; - }; - - // Turn on `emulateHTTP` to support legacy HTTP servers. Setting this option - // will fake `"PUT"` and `"DELETE"` requests via the `_method` parameter and - // set a `X-Http-Method-Override` header. - Backbone.emulateHTTP = false; - - // Turn on `emulateJSON` to support legacy servers that can't deal with direct - // `application/json` requests ... will encode the body as - // `application/x-www-form-urlencoded` instead and will send the model in a - // form param named `model`. - Backbone.emulateJSON = false; - - // Backbone.Events - // --------------- - - // A module that can be mixed in to *any object* in order to provide it with - // custom events. You may bind with `on` or remove with `off` callback - // functions to an event; `trigger`-ing an event fires all callbacks in - // succession. - // - // var object = {}; - // _.extend(object, Backbone.Events); - // object.on('expand', function(){ alert('expanded'); }); - // object.trigger('expand'); - // - var Events = Backbone.Events = { - - // Bind an event to a `callback` function. Passing `"all"` will bind - // the callback to all events fired. - on: function(name, callback, context) { - if (!eventsApi(this, 'on', name, [callback, context]) || !callback) return this; - this._events || (this._events = {}); - var events = this._events[name] || (this._events[name] = []); - events.push({callback: callback, context: context, ctx: context || this}); - return this; - }, - - // Bind an event to only be triggered a single time. After the first time - // the callback is invoked, it will be removed. - once: function(name, callback, context) { - if (!eventsApi(this, 'once', name, [callback, context]) || !callback) return this; - var self = this; - var once = _.once(function() { - self.off(name, once); - callback.apply(this, arguments); - }); - once._callback = callback; - return this.on(name, once, context); - }, - - // Remove one or many callbacks. If `context` is null, removes all - // callbacks with that function. If `callback` is null, removes all - // callbacks for the event. If `name` is null, removes all bound - // callbacks for all events. - off: function(name, callback, context) { - var retain, ev, events, names, i, l, j, k; - if (!this._events || !eventsApi(this, 'off', name, [callback, context])) return this; - if (!name && !callback && !context) { - this._events = {}; - return this; - } - - names = name ? [name] : _.keys(this._events); - for (i = 0, l = names.length; i < l; i++) { - name = names[i]; - if (events = this._events[name]) { - this._events[name] = retain = []; - if (callback || context) { - for (j = 0, k = events.length; j < k; j++) { - ev = events[j]; - if ((callback && callback !== ev.callback && callback !== ev.callback._callback) || - (context && context !== ev.context)) { - retain.push(ev); - } - } - } - if (!retain.length) delete this._events[name]; - } - } - - return this; - }, - - // Trigger one or many events, firing all bound callbacks. Callbacks are - // passed the same arguments as `trigger` is, apart from the event name - // (unless you're listening on `"all"`, which will cause your callback to - // receive the true name of the event as the first argument). - trigger: function(name) { - if (!this._events) return this; - var args = slice.call(arguments, 1); - if (!eventsApi(this, 'trigger', name, args)) return this; - var events = this._events[name]; - var allEvents = this._events.all; - if (events) triggerEvents(events, args); - if (allEvents) triggerEvents(allEvents, arguments); - return this; - }, - - // Tell this object to stop listening to either specific events ... or - // to every object it's currently listening to. - stopListening: function(obj, name, callback) { - var listeners = this._listeners; - if (!listeners) return this; - var deleteListener = !name && !callback; - if (typeof name === 'object') callback = this; - if (obj) (listeners = {})[obj._listenerId] = obj; - for (var id in listeners) { - listeners[id].off(name, callback, this); - if (deleteListener) delete this._listeners[id]; - } - return this; - } - - }; - - // Regular expression used to split event strings. - var eventSplitter = /\s+/; - - // Implement fancy features of the Events API such as multiple event - // names `"change blur"` and jQuery-style event maps `{change: action}` - // in terms of the existing API. - var eventsApi = function(obj, action, name, rest) { - if (!name) return true; - - // Handle event maps. - if (typeof name === 'object') { - for (var key in name) { - obj[action].apply(obj, [key, name[key]].concat(rest)); - } - return false; - } - - // Handle space separated event names. - if (eventSplitter.test(name)) { - var names = name.split(eventSplitter); - for (var i = 0, l = names.length; i < l; i++) { - obj[action].apply(obj, [names[i]].concat(rest)); - } - return false; - } - - return true; - }; - - // A difficult-to-believe, but optimized internal dispatch function for - // triggering events. Tries to keep the usual cases speedy (most internal - // Backbone events have 3 arguments). - var triggerEvents = function(events, args) { - var ev, i = -1, l = events.length, a1 = args[0], a2 = args[1], a3 = args[2]; - switch (args.length) { - case 0: while (++i < l) (ev = events[i]).callback.call(ev.ctx); return; - case 1: while (++i < l) (ev = events[i]).callback.call(ev.ctx, a1); return; - case 2: while (++i < l) (ev = events[i]).callback.call(ev.ctx, a1, a2); return; - case 3: while (++i < l) (ev = events[i]).callback.call(ev.ctx, a1, a2, a3); return; - default: while (++i < l) (ev = events[i]).callback.apply(ev.ctx, args); - } - }; - - var listenMethods = {listenTo: 'on', listenToOnce: 'once'}; - - // Inversion-of-control versions of `on` and `once`. Tell *this* object to - // listen to an event in another object ... keeping track of what it's - // listening to. - _.each(listenMethods, function(implementation, method) { - Events[method] = function(obj, name, callback) { - var listeners = this._listeners || (this._listeners = {}); - var id = obj._listenerId || (obj._listenerId = _.uniqueId('l')); - listeners[id] = obj; - if (typeof name === 'object') callback = this; - obj[implementation](name, callback, this); - return this; - }; - }); - - // Aliases for backwards compatibility. - Events.bind = Events.on; - Events.unbind = Events.off; - - // Allow the `Backbone` object to serve as a global event bus, for folks who - // want global "pubsub" in a convenient place. - _.extend(Backbone, Events); - - // Backbone.Model - // -------------- - - // Backbone **Models** are the basic data object in the framework -- - // frequently representing a row in a table in a database on your server. - // A discrete chunk of data and a bunch of useful, related methods for - // performing computations and transformations on that data. - - // Create a new model with the specified attributes. A client id (`cid`) - // is automatically generated and assigned for you. - var Model = Backbone.Model = function(attributes, options) { - var defaults; - var attrs = attributes || {}; - options || (options = {}); - this.cid = _.uniqueId('c'); - this.attributes = {}; - _.extend(this, _.pick(options, modelOptions)); - if (options.parse) attrs = this.parse(attrs, options) || {}; - if (defaults = _.result(this, 'defaults')) { - attrs = _.defaults({}, attrs, defaults); - } - this.set(attrs, options); - this.changed = {}; - this.initialize.apply(this, arguments); - }; - - // A list of options to be attached directly to the model, if provided. - var modelOptions = ['url', 'urlRoot', 'collection']; - - // Attach all inheritable methods to the Model prototype. - _.extend(Model.prototype, Events, { - - // A hash of attributes whose current and previous value differ. - changed: null, - - // The value returned during the last failed validation. - validationError: null, - - // The default name for the JSON `id` attribute is `"id"`. MongoDB and - // CouchDB users may want to set this to `"_id"`. - idAttribute: 'id', - - // Initialize is an empty function by default. Override it with your own - // initialization logic. - initialize: function(){}, - - // Return a copy of the model's `attributes` object. - toJSON: function(options) { - return _.clone(this.attributes); - }, - - // Proxy `Backbone.sync` by default -- but override this if you need - // custom syncing semantics for *this* particular model. - sync: function() { - return Backbone.sync.apply(this, arguments); - }, - - // Get the value of an attribute. - get: function(attr) { - return this.attributes[attr]; - }, - - // Get the HTML-escaped value of an attribute. - escape: function(attr) { - return _.escape(this.get(attr)); - }, - - // Returns `true` if the attribute contains a value that is not null - // or undefined. - has: function(attr) { - return this.get(attr) != null; - }, - - // Set a hash of model attributes on the object, firing `"change"`. This is - // the core primitive operation of a model, updating the data and notifying - // anyone who needs to know about the change in state. The heart of the beast. - set: function(key, val, options) { - var attr, attrs, unset, changes, silent, changing, prev, current; - if (key == null) return this; - - // Handle both `"key", value` and `{key: value}` -style arguments. - if (typeof key === 'object') { - attrs = key; - options = val; - } else { - (attrs = {})[key] = val; - } - - options || (options = {}); - - // Run validation. - if (!this._validate(attrs, options)) return false; - - // Extract attributes and options. - unset = options.unset; - silent = options.silent; - changes = []; - changing = this._changing; - this._changing = true; - - if (!changing) { - this._previousAttributes = _.clone(this.attributes); - this.changed = {}; - } - current = this.attributes, prev = this._previousAttributes; - - // Check for changes of `id`. - if (this.idAttribute in attrs) this.id = attrs[this.idAttribute]; - - // For each `set` attribute, update or delete the current value. - for (attr in attrs) { - val = attrs[attr]; - if (!_.isEqual(current[attr], val)) changes.push(attr); - if (!_.isEqual(prev[attr], val)) { - this.changed[attr] = val; - } else { - delete this.changed[attr]; - } - unset ? delete current[attr] : current[attr] = val; - } - - // Trigger all relevant attribute changes. - if (!silent) { - if (changes.length) this._pending = true; - for (var i = 0, l = changes.length; i < l; i++) { - this.trigger('change:' + changes[i], this, current[changes[i]], options); - } - } - - // You might be wondering why there's a `while` loop here. Changes can - // be recursively nested within `"change"` events. - if (changing) return this; - if (!silent) { - while (this._pending) { - this._pending = false; - this.trigger('change', this, options); - } - } - this._pending = false; - this._changing = false; - return this; - }, - - // Remove an attribute from the model, firing `"change"`. `unset` is a noop - // if the attribute doesn't exist. - unset: function(attr, options) { - return this.set(attr, void 0, _.extend({}, options, {unset: true})); - }, - - // Clear all attributes on the model, firing `"change"`. - clear: function(options) { - var attrs = {}; - for (var key in this.attributes) attrs[key] = void 0; - return this.set(attrs, _.extend({}, options, {unset: true})); - }, - - // Determine if the model has changed since the last `"change"` event. - // If you specify an attribute name, determine if that attribute has changed. - hasChanged: function(attr) { - if (attr == null) return !_.isEmpty(this.changed); - return _.has(this.changed, attr); - }, - - // Return an object containing all the attributes that have changed, or - // false if there are no changed attributes. Useful for determining what - // parts of a view need to be updated and/or what attributes need to be - // persisted to the server. Unset attributes will be set to undefined. - // You can also pass an attributes object to diff against the model, - // determining if there *would be* a change. - changedAttributes: function(diff) { - if (!diff) return this.hasChanged() ? _.clone(this.changed) : false; - var val, changed = false; - var old = this._changing ? this._previousAttributes : this.attributes; - for (var attr in diff) { - if (_.isEqual(old[attr], (val = diff[attr]))) continue; - (changed || (changed = {}))[attr] = val; - } - return changed; - }, - - // Get the previous value of an attribute, recorded at the time the last - // `"change"` event was fired. - previous: function(attr) { - if (attr == null || !this._previousAttributes) return null; - return this._previousAttributes[attr]; - }, - - // Get all of the attributes of the model at the time of the previous - // `"change"` event. - previousAttributes: function() { - return _.clone(this._previousAttributes); - }, - - // Fetch the model from the server. If the server's representation of the - // model differs from its current attributes, they will be overridden, - // triggering a `"change"` event. - fetch: function(options) { - options = options ? _.clone(options) : {}; - if (options.parse === void 0) options.parse = true; - var model = this; - var success = options.success; - options.success = function(resp) { - if (!model.set(model.parse(resp, options), options)) return false; - if (success) success(model, resp, options); - model.trigger('sync', model, resp, options); - }; - wrapError(this, options); - return this.sync('read', this, options); - }, - - // Set a hash of model attributes, and sync the model to the server. - // If the server returns an attributes hash that differs, the model's - // state will be `set` again. - save: function(key, val, options) { - var attrs, method, xhr, attributes = this.attributes; - - // Handle both `"key", value` and `{key: value}` -style arguments. - if (key == null || typeof key === 'object') { - attrs = key; - options = val; - } else { - (attrs = {})[key] = val; - } - - // If we're not waiting and attributes exist, save acts as `set(attr).save(null, opts)`. - if (attrs && (!options || !options.wait) && !this.set(attrs, options)) return false; - - options = _.extend({validate: true}, options); - - // Do not persist invalid models. - if (!this._validate(attrs, options)) return false; - - // Set temporary attributes if `{wait: true}`. - if (attrs && options.wait) { - this.attributes = _.extend({}, attributes, attrs); - } - - // After a successful server-side save, the client is (optionally) - // updated with the server-side state. - if (options.parse === void 0) options.parse = true; - var model = this; - var success = options.success; - options.success = function(resp) { - // Ensure attributes are restored during synchronous saves. - model.attributes = attributes; - var serverAttrs = model.parse(resp, options); - if (options.wait) serverAttrs = _.extend(attrs || {}, serverAttrs); - if (_.isObject(serverAttrs) && !model.set(serverAttrs, options)) { - return false; - } - if (success) success(model, resp, options); - model.trigger('sync', model, resp, options); - }; - wrapError(this, options); - - method = this.isNew() ? 'create' : (options.patch ? 'patch' : 'update'); - if (method === 'patch') options.attrs = attrs; - xhr = this.sync(method, this, options); - - // Restore attributes. - if (attrs && options.wait) this.attributes = attributes; - - return xhr; - }, - - // Destroy this model on the server if it was already persisted. - // Optimistically removes the model from its collection, if it has one. - // If `wait: true` is passed, waits for the server to respond before removal. - destroy: function(options) { - options = options ? _.clone(options) : {}; - var model = this; - var success = options.success; - - var destroy = function() { - model.trigger('destroy', model, model.collection, options); - }; - - options.success = function(resp) { - if (options.wait || model.isNew()) destroy(); - if (success) success(model, resp, options); - if (!model.isNew()) model.trigger('sync', model, resp, options); - }; - - if (this.isNew()) { - options.success(); - return false; - } - wrapError(this, options); - - var xhr = this.sync('delete', this, options); - if (!options.wait) destroy(); - return xhr; - }, - - // Default URL for the model's representation on the server -- if you're - // using Backbone's restful methods, override this to change the endpoint - // that will be called. - url: function() { - var base = _.result(this, 'urlRoot') || _.result(this.collection, 'url') || urlError(); - if (this.isNew()) return base; - return base + (base.charAt(base.length - 1) === '/' ? '' : '/') + encodeURIComponent(this.id); - }, - - // **parse** converts a response into the hash of attributes to be `set` on - // the model. The default implementation is just to pass the response along. - parse: function(resp, options) { - return resp; - }, - - // Create a new model with identical attributes to this one. - clone: function() { - return new this.constructor(this.attributes); - }, - - // A model is new if it has never been saved to the server, and lacks an id. - isNew: function() { - return this.id == null; - }, - - // Check if the model is currently in a valid state. - isValid: function(options) { - return this._validate({}, _.extend(options || {}, { validate: true })); - }, - - // Run validation against the next complete set of model attributes, - // returning `true` if all is well. Otherwise, fire an `"invalid"` event. - _validate: function(attrs, options) { - if (!options.validate || !this.validate) return true; - attrs = _.extend({}, this.attributes, attrs); - var error = this.validationError = this.validate(attrs, options) || null; - if (!error) return true; - this.trigger('invalid', this, error, _.extend(options || {}, {validationError: error})); - return false; - } - - }); - - // Underscore methods that we want to implement on the Model. - var modelMethods = ['keys', 'values', 'pairs', 'invert', 'pick', 'omit']; - - // Mix in each Underscore method as a proxy to `Model#attributes`. - _.each(modelMethods, function(method) { - Model.prototype[method] = function() { - var args = slice.call(arguments); - args.unshift(this.attributes); - return _[method].apply(_, args); - }; - }); - - // Backbone.Collection - // ------------------- - - // If models tend to represent a single row of data, a Backbone Collection is - // more analagous to a table full of data ... or a small slice or page of that - // table, or a collection of rows that belong together for a particular reason - // -- all of the messages in this particular folder, all of the documents - // belonging to this particular author, and so on. Collections maintain - // indexes of their models, both in order, and for lookup by `id`. - - // Create a new **Collection**, perhaps to contain a specific type of `model`. - // If a `comparator` is specified, the Collection will maintain - // its models in sort order, as they're added and removed. - var Collection = Backbone.Collection = function(models, options) { - options || (options = {}); - if (options.url) this.url = options.url; - if (options.model) this.model = options.model; - if (options.comparator !== void 0) this.comparator = options.comparator; - this._reset(); - this.initialize.apply(this, arguments); - if (models) this.reset(models, _.extend({silent: true}, options)); - }; - - // Default options for `Collection#set`. - var setOptions = {add: true, remove: true, merge: true}; - var addOptions = {add: true, merge: false, remove: false}; - - // Define the Collection's inheritable methods. - _.extend(Collection.prototype, Events, { - - // The default model for a collection is just a **Backbone.Model**. - // This should be overridden in most cases. - model: Model, - - // Initialize is an empty function by default. Override it with your own - // initialization logic. - initialize: function(){}, - - // The JSON representation of a Collection is an array of the - // models' attributes. - toJSON: function(options) { - return this.map(function(model){ return model.toJSON(options); }); - }, - - // Proxy `Backbone.sync` by default. - sync: function() { - return Backbone.sync.apply(this, arguments); - }, - - // Add a model, or list of models to the set. - add: function(models, options) { - return this.set(models, _.defaults(options || {}, addOptions)); - }, - - // Remove a model, or a list of models from the set. - remove: function(models, options) { - models = _.isArray(models) ? models.slice() : [models]; - options || (options = {}); - var i, l, index, model; - for (i = 0, l = models.length; i < l; i++) { - model = this.get(models[i]); - if (!model) continue; - delete this._byId[model.id]; - delete this._byId[model.cid]; - index = this.indexOf(model); - this.models.splice(index, 1); - this.length--; - if (!options.silent) { - options.index = index; - model.trigger('remove', model, this, options); - } - this._removeReference(model); - } - return this; - }, - - // Update a collection by `set`-ing a new list of models, adding new ones, - // removing models that are no longer present, and merging models that - // already exist in the collection, as necessary. Similar to **Model#set**, - // the core operation for updating the data contained by the collection. - set: function(models, options) { - options = _.defaults(options || {}, setOptions); - if (options.parse) models = this.parse(models, options); - if (!_.isArray(models)) models = models ? [models] : []; - var i, l, model, attrs, existing, sort; - var at = options.at; - var sortable = this.comparator && (at == null) && options.sort !== false; - var sortAttr = _.isString(this.comparator) ? this.comparator : null; - var toAdd = [], toRemove = [], modelMap = {}; - - // Turn bare objects into model references, and prevent invalid models - // from being added. - for (i = 0, l = models.length; i < l; i++) { - if (!(model = this._prepareModel(models[i], options))) continue; - - // If a duplicate is found, prevent it from being added and - // optionally merge it into the existing model. - if (existing = this.get(model)) { - if (options.remove) modelMap[existing.cid] = true; - if (options.merge) { - existing.set(model.attributes, options); - if (sortable && !sort && existing.hasChanged(sortAttr)) sort = true; - } - - // This is a new model, push it to the `toAdd` list. - } else if (options.add) { - toAdd.push(model); - - // Listen to added models' events, and index models for lookup by - // `id` and by `cid`. - model.on('all', this._onModelEvent, this); - this._byId[model.cid] = model; - if (model.id != null) this._byId[model.id] = model; - } - } - - // Remove nonexistent models if appropriate. - if (options.remove) { - for (i = 0, l = this.length; i < l; ++i) { - if (!modelMap[(model = this.models[i]).cid]) toRemove.push(model); - } - if (toRemove.length) this.remove(toRemove, options); - } - - // See if sorting is needed, update `length` and splice in new models. - if (toAdd.length) { - if (sortable) sort = true; - this.length += toAdd.length; - if (at != null) { - splice.apply(this.models, [at, 0].concat(toAdd)); - } else { - push.apply(this.models, toAdd); - } - } - - // Silently sort the collection if appropriate. - if (sort) this.sort({silent: true}); - - if (options.silent) return this; - - // Trigger `add` events. - for (i = 0, l = toAdd.length; i < l; i++) { - (model = toAdd[i]).trigger('add', model, this, options); - } - - // Trigger `sort` if the collection was sorted. - if (sort) this.trigger('sort', this, options); - return this; - }, - - // When you have more items than you want to add or remove individually, - // you can reset the entire set with a new list of models, without firing - // any granular `add` or `remove` events. Fires `reset` when finished. - // Useful for bulk operations and optimizations. - reset: function(models, options) { - options || (options = {}); - for (var i = 0, l = this.models.length; i < l; i++) { - this._removeReference(this.models[i]); - } - options.previousModels = this.models; - this._reset(); - this.add(models, _.extend({silent: true}, options)); - if (!options.silent) this.trigger('reset', this, options); - return this; - }, - - // Add a model to the end of the collection. - push: function(model, options) { - model = this._prepareModel(model, options); - this.add(model, _.extend({at: this.length}, options)); - return model; - }, - - // Remove a model from the end of the collection. - pop: function(options) { - var model = this.at(this.length - 1); - this.remove(model, options); - return model; - }, - - // Add a model to the beginning of the collection. - unshift: function(model, options) { - model = this._prepareModel(model, options); - this.add(model, _.extend({at: 0}, options)); - return model; - }, - - // Remove a model from the beginning of the collection. - shift: function(options) { - var model = this.at(0); - this.remove(model, options); - return model; - }, - - // Slice out a sub-array of models from the collection. - slice: function(begin, end) { - return this.models.slice(begin, end); - }, - - // Get a model from the set by id. - get: function(obj) { - if (obj == null) return void 0; - return this._byId[obj.id != null ? obj.id : obj.cid || obj]; - }, - - // Get the model at the given index. - at: function(index) { - return this.models[index]; - }, - - // Return models with matching attributes. Useful for simple cases of - // `filter`. - where: function(attrs, first) { - if (_.isEmpty(attrs)) return first ? void 0 : []; - return this[first ? 'find' : 'filter'](function(model) { - for (var key in attrs) { - if (attrs[key] !== model.get(key)) return false; - } - return true; - }); - }, - - // Return the first model with matching attributes. Useful for simple cases - // of `find`. - findWhere: function(attrs) { - return this.where(attrs, true); - }, - - // Force the collection to re-sort itself. You don't need to call this under - // normal circumstances, as the set will maintain sort order as each item - // is added. - sort: function(options) { - if (!this.comparator) throw new Error('Cannot sort a set without a comparator'); - options || (options = {}); - - // Run sort based on type of `comparator`. - if (_.isString(this.comparator) || this.comparator.length === 1) { - this.models = this.sortBy(this.comparator, this); - } else { - this.models.sort(_.bind(this.comparator, this)); - } - - if (!options.silent) this.trigger('sort', this, options); - return this; - }, - - // Figure out the smallest index at which a model should be inserted so as - // to maintain order. - sortedIndex: function(model, value, context) { - value || (value = this.comparator); - var iterator = _.isFunction(value) ? value : function(model) { - return model.get(value); - }; - return _.sortedIndex(this.models, model, iterator, context); - }, - - // Pluck an attribute from each model in the collection. - pluck: function(attr) { - return _.invoke(this.models, 'get', attr); - }, - - // Fetch the default set of models for this collection, resetting the - // collection when they arrive. If `reset: true` is passed, the response - // data will be passed through the `reset` method instead of `set`. - fetch: function(options) { - options = options ? _.clone(options) : {}; - if (options.parse === void 0) options.parse = true; - var success = options.success; - var collection = this; - options.success = function(resp) { - var method = options.reset ? 'reset' : 'set'; - collection[method](resp, options); - if (success) success(collection, resp, options); - collection.trigger('sync', collection, resp, options); - }; - wrapError(this, options); - return this.sync('read', this, options); - }, - - // Create a new instance of a model in this collection. Add the model to the - // collection immediately, unless `wait: true` is passed, in which case we - // wait for the server to agree. - create: function(model, options) { - options = options ? _.clone(options) : {}; - if (!(model = this._prepareModel(model, options))) return false; - if (!options.wait) this.add(model, options); - var collection = this; - var success = options.success; - options.success = function(resp) { - if (options.wait) collection.add(model, options); - if (success) success(model, resp, options); - }; - model.save(null, options); - return model; - }, - - // **parse** converts a response into a list of models to be added to the - // collection. The default implementation is just to pass it through. - parse: function(resp, options) { - return resp; - }, - - // Create a new collection with an identical list of models as this one. - clone: function() { - return new this.constructor(this.models); - }, - - // Private method to reset all internal state. Called when the collection - // is first initialized or reset. - _reset: function() { - this.length = 0; - this.models = []; - this._byId = {}; - }, - - // Prepare a hash of attributes (or other model) to be added to this - // collection. - _prepareModel: function(attrs, options) { - if (attrs instanceof Model) { - if (!attrs.collection) attrs.collection = this; - return attrs; - } - options || (options = {}); - options.collection = this; - var model = new this.model(attrs, options); - if (!model._validate(attrs, options)) { - this.trigger('invalid', this, attrs, options); - return false; - } - return model; - }, - - // Internal method to sever a model's ties to a collection. - _removeReference: function(model) { - if (this === model.collection) delete model.collection; - model.off('all', this._onModelEvent, this); - }, - - // Internal method called every time a model in the set fires an event. - // Sets need to update their indexes when models change ids. All other - // events simply proxy through. "add" and "remove" events that originate - // in other collections are ignored. - _onModelEvent: function(event, model, collection, options) { - if ((event === 'add' || event === 'remove') && collection !== this) return; - if (event === 'destroy') this.remove(model, options); - if (model && event === 'change:' + model.idAttribute) { - delete this._byId[model.previous(model.idAttribute)]; - if (model.id != null) this._byId[model.id] = model; - } - this.trigger.apply(this, arguments); - } - - }); - - // Underscore methods that we want to implement on the Collection. - // 90% of the core usefulness of Backbone Collections is actually implemented - // right here: - var methods = ['forEach', 'each', 'map', 'collect', 'reduce', 'foldl', - 'inject', 'reduceRight', 'foldr', 'find', 'detect', 'filter', 'select', - 'reject', 'every', 'all', 'some', 'any', 'include', 'contains', 'invoke', - 'max', 'min', 'toArray', 'size', 'first', 'head', 'take', 'initial', 'rest', - 'tail', 'drop', 'last', 'without', 'indexOf', 'shuffle', 'lastIndexOf', - 'isEmpty', 'chain']; - - // Mix in each Underscore method as a proxy to `Collection#models`. - _.each(methods, function(method) { - Collection.prototype[method] = function() { - var args = slice.call(arguments); - args.unshift(this.models); - return _[method].apply(_, args); - }; - }); - - // Underscore methods that take a property name as an argument. - var attributeMethods = ['groupBy', 'countBy', 'sortBy']; - - // Use attributes instead of properties. - _.each(attributeMethods, function(method) { - Collection.prototype[method] = function(value, context) { - var iterator = _.isFunction(value) ? value : function(model) { - return model.get(value); - }; - return _[method](this.models, iterator, context); - }; - }); - - // Backbone.View - // ------------- - - // Backbone Views are almost more convention than they are actual code. A View - // is simply a JavaScript object that represents a logical chunk of UI in the - // DOM. This might be a single item, an entire list, a sidebar or panel, or - // even the surrounding frame which wraps your whole app. Defining a chunk of - // UI as a **View** allows you to define your DOM events declaratively, without - // having to worry about render order ... and makes it easy for the view to - // react to specific changes in the state of your models. - - // Creating a Backbone.View creates its initial element outside of the DOM, - // if an existing element is not provided... - var View = Backbone.View = function(options) { - this.cid = _.uniqueId('view'); - this._configure(options || {}); - this._ensureElement(); - this.initialize.apply(this, arguments); - this.delegateEvents(); - }; - - // Cached regex to split keys for `delegate`. - var delegateEventSplitter = /^(\S+)\s*(.*)$/; - - // List of view options to be merged as properties. - var viewOptions = ['model', 'collection', 'el', 'id', 'attributes', 'className', 'tagName', 'events']; - - // Set up all inheritable **Backbone.View** properties and methods. - _.extend(View.prototype, Events, { - - // The default `tagName` of a View's element is `"div"`. - tagName: 'div', - - // jQuery delegate for element lookup, scoped to DOM elements within the - // current view. This should be prefered to global lookups where possible. - $: function(selector) { - return this.$el.find(selector); - }, - - // Initialize is an empty function by default. Override it with your own - // initialization logic. - initialize: function(){}, - - // **render** is the core function that your view should override, in order - // to populate its element (`this.el`), with the appropriate HTML. The - // convention is for **render** to always return `this`. - render: function() { - return this; - }, - - // Remove this view by taking the element out of the DOM, and removing any - // applicable Backbone.Events listeners. - remove: function() { - this.$el.remove(); - this.stopListening(); - return this; - }, - - // Change the view's element (`this.el` property), including event - // re-delegation. - setElement: function(element, delegate) { - if (this.$el) this.undelegateEvents(); - this.$el = element instanceof Backbone.$ ? element : Backbone.$(element); - this.el = this.$el[0]; - if (delegate !== false) this.delegateEvents(); - return this; - }, - - // Set callbacks, where `this.events` is a hash of - // - // *{"event selector": "callback"}* - // - // { - // 'mousedown .title': 'edit', - // 'click .button': 'save' - // 'click .open': function(e) { ... } - // } - // - // pairs. Callbacks will be bound to the view, with `this` set properly. - // Uses event delegation for efficiency. - // Omitting the selector binds the event to `this.el`. - // This only works for delegate-able events: not `focus`, `blur`, and - // not `change`, `submit`, and `reset` in Internet Explorer. - delegateEvents: function(events) { - if (!(events || (events = _.result(this, 'events')))) return this; - this.undelegateEvents(); - for (var key in events) { - var method = events[key]; - if (!_.isFunction(method)) method = this[events[key]]; - if (!method) continue; - - var match = key.match(delegateEventSplitter); - var eventName = match[1], selector = match[2]; - method = _.bind(method, this); - eventName += '.delegateEvents' + this.cid; - if (selector === '') { - this.$el.on(eventName, method); - } else { - this.$el.on(eventName, selector, method); - } - } - return this; - }, - - // Clears all callbacks previously bound to the view with `delegateEvents`. - // You usually don't need to use this, but may wish to if you have multiple - // Backbone views attached to the same DOM element. - undelegateEvents: function() { - this.$el.off('.delegateEvents' + this.cid); - return this; - }, - - // Performs the initial configuration of a View with a set of options. - // Keys with special meaning *(e.g. model, collection, id, className)* are - // attached directly to the view. See `viewOptions` for an exhaustive - // list. - _configure: function(options) { - if (this.options) options = _.extend({}, _.result(this, 'options'), options); - _.extend(this, _.pick(options, viewOptions)); - this.options = options; - }, - - // Ensure that the View has a DOM element to render into. - // If `this.el` is a string, pass it through `$()`, take the first - // matching element, and re-assign it to `el`. Otherwise, create - // an element from the `id`, `className` and `tagName` properties. - _ensureElement: function() { - if (!this.el) { - var attrs = _.extend({}, _.result(this, 'attributes')); - if (this.id) attrs.id = _.result(this, 'id'); - if (this.className) attrs['class'] = _.result(this, 'className'); - var $el = Backbone.$('<' + _.result(this, 'tagName') + '>').attr(attrs); - this.setElement($el, false); - } else { - this.setElement(_.result(this, 'el'), false); - } - } - - }); - - // Backbone.sync - // ------------- - - // Override this function to change the manner in which Backbone persists - // models to the server. You will be passed the type of request, and the - // model in question. By default, makes a RESTful Ajax request - // to the model's `url()`. Some possible customizations could be: - // - // * Use `setTimeout` to batch rapid-fire updates into a single request. - // * Send up the models as XML instead of JSON. - // * Persist models via WebSockets instead of Ajax. - // - // Turn on `Backbone.emulateHTTP` in order to send `PUT` and `DELETE` requests - // as `POST`, with a `_method` parameter containing the true HTTP method, - // as well as all requests with the body as `application/x-www-form-urlencoded` - // instead of `application/json` with the model in a param named `model`. - // Useful when interfacing with server-side languages like **PHP** that make - // it difficult to read the body of `PUT` requests. - Backbone.sync = function(method, model, options) { - var type = methodMap[method]; - - // Default options, unless specified. - _.defaults(options || (options = {}), { - emulateHTTP: Backbone.emulateHTTP, - emulateJSON: Backbone.emulateJSON - }); - - // Default JSON-request options. - var params = {type: type, dataType: 'json'}; - - // Ensure that we have a URL. - if (!options.url) { - params.url = _.result(model, 'url') || urlError(); - } - - // Ensure that we have the appropriate request data. - if (options.data == null && model && (method === 'create' || method === 'update' || method === 'patch')) { - params.contentType = 'application/json'; - params.data = JSON.stringify(options.attrs || model.toJSON(options)); - } - - // For older servers, emulate JSON by encoding the request into an HTML-form. - if (options.emulateJSON) { - params.contentType = 'application/x-www-form-urlencoded'; - params.data = params.data ? {model: params.data} : {}; - } - - // For older servers, emulate HTTP by mimicking the HTTP method with `_method` - // And an `X-HTTP-Method-Override` header. - if (options.emulateHTTP && (type === 'PUT' || type === 'DELETE' || type === 'PATCH')) { - params.type = 'POST'; - if (options.emulateJSON) params.data._method = type; - var beforeSend = options.beforeSend; - options.beforeSend = function(xhr) { - xhr.setRequestHeader('X-HTTP-Method-Override', type); - if (beforeSend) return beforeSend.apply(this, arguments); - }; - } - - // Don't process data on a non-GET request. - if (params.type !== 'GET' && !options.emulateJSON) { - params.processData = false; - } - - // If we're sending a `PATCH` request, and we're in an old Internet Explorer - // that still has ActiveX enabled by default, override jQuery to use that - // for XHR instead. Remove this line when jQuery supports `PATCH` on IE8. - if (params.type === 'PATCH' && window.ActiveXObject && - !(window.external && window.external.msActiveXFilteringEnabled)) { - params.xhr = function() { - return new ActiveXObject("Microsoft.XMLHTTP"); - }; - } - - // Make the request, allowing the user to override any Ajax options. - var xhr = options.xhr = Backbone.ajax(_.extend(params, options)); - model.trigger('request', model, xhr, options); - return xhr; - }; - - // Map from CRUD to HTTP for our default `Backbone.sync` implementation. - var methodMap = { - 'create': 'POST', - 'update': 'PUT', - 'patch': 'PATCH', - 'delete': 'DELETE', - 'read': 'GET' - }; - - // Set the default implementation of `Backbone.ajax` to proxy through to `$`. - // Override this if you'd like to use a different library. - Backbone.ajax = function() { - return Backbone.$.ajax.apply(Backbone.$, arguments); - }; - - // Backbone.Router - // --------------- - - // Routers map faux-URLs to actions, and fire events when routes are - // matched. Creating a new one sets its `routes` hash, if not set statically. - var Router = Backbone.Router = function(options) { - options || (options = {}); - if (options.routes) this.routes = options.routes; - this._bindRoutes(); - this.initialize.apply(this, arguments); - }; - - // Cached regular expressions for matching named param parts and splatted - // parts of route strings. - var optionalParam = /\((.*?)\)/g; - var namedParam = /(\(\?)?:\w+/g; - var splatParam = /\*\w+/g; - var escapeRegExp = /[\-{}\[\]+?.,\\\^$|#\s]/g; - - // Set up all inheritable **Backbone.Router** properties and methods. - _.extend(Router.prototype, Events, { - - // Initialize is an empty function by default. Override it with your own - // initialization logic. - initialize: function(){}, - - // Manually bind a single named route to a callback. For example: - // - // this.route('search/:query/p:num', 'search', function(query, num) { - // ... - // }); - // - route: function(route, name, callback) { - if (!_.isRegExp(route)) route = this._routeToRegExp(route); - if (_.isFunction(name)) { - callback = name; - name = ''; - } - if (!callback) callback = this[name]; - var router = this; - Backbone.history.route(route, function(fragment) { - var args = router._extractParameters(route, fragment); - callback && callback.apply(router, args); - router.trigger.apply(router, ['route:' + name].concat(args)); - router.trigger('route', name, args); - Backbone.history.trigger('route', router, name, args); - }); - return this; - }, - - // Simple proxy to `Backbone.history` to save a fragment into the history. - navigate: function(fragment, options) { - Backbone.history.navigate(fragment, options); - return this; - }, - - // Bind all defined routes to `Backbone.history`. We have to reverse the - // order of the routes here to support behavior where the most general - // routes can be defined at the bottom of the route map. - _bindRoutes: function() { - if (!this.routes) return; - this.routes = _.result(this, 'routes'); - var route, routes = _.keys(this.routes); - while ((route = routes.pop()) != null) { - this.route(route, this.routes[route]); - } - }, - - // Convert a route string into a regular expression, suitable for matching - // against the current location hash. - _routeToRegExp: function(route) { - route = route.replace(escapeRegExp, '\\$&') - .replace(optionalParam, '(?:$1)?') - .replace(namedParam, function(match, optional){ - return optional ? match : '([^\/]+)'; - }) - .replace(splatParam, '(.*?)'); - return new RegExp('^' + route + '$'); - }, - - // Given a route, and a URL fragment that it matches, return the array of - // extracted decoded parameters. Empty or unmatched parameters will be - // treated as `null` to normalize cross-browser behavior. - _extractParameters: function(route, fragment) { - var params = route.exec(fragment).slice(1); - return _.map(params, function(param) { - return param ? decodeURIComponent(param) : null; - }); - } - - }); - - // Backbone.History - // ---------------- - - // Handles cross-browser history management, based on either - // [pushState](http://diveintohtml5.info/history.html) and real URLs, or - // [onhashchange](https://developer.mozilla.org/en-US/docs/DOM/window.onhashchange) - // and URL fragments. If the browser supports neither (old IE, natch), - // falls back to polling. - var History = Backbone.History = function() { - this.handlers = []; - _.bindAll(this, 'checkUrl'); - - // Ensure that `History` can be used outside of the browser. - if (typeof window !== 'undefined') { - this.location = window.location; - this.history = window.history; - } - }; - - // Cached regex for stripping a leading hash/slash and trailing space. - var routeStripper = /^[#\/]|\s+$/g; - - // Cached regex for stripping leading and trailing slashes. - var rootStripper = /^\/+|\/+$/g; - - // Cached regex for detecting MSIE. - var isExplorer = /msie [\w.]+/; - - // Cached regex for removing a trailing slash. - var trailingSlash = /\/$/; - - // Has the history handling already been started? - History.started = false; - - // Set up all inheritable **Backbone.History** properties and methods. - _.extend(History.prototype, Events, { - - // The default interval to poll for hash changes, if necessary, is - // twenty times a second. - interval: 50, - - // Gets the true hash value. Cannot use location.hash directly due to bug - // in Firefox where location.hash will always be decoded. - getHash: function(window) { - var match = (window || this).location.href.match(/#(.*)$/); - return match ? match[1] : ''; - }, - - // Get the cross-browser normalized URL fragment, either from the URL, - // the hash, or the override. - getFragment: function(fragment, forcePushState) { - if (fragment == null) { - if (this._hasPushState || !this._wantsHashChange || forcePushState) { - fragment = this.location.pathname; - var root = this.root.replace(trailingSlash, ''); - if (!fragment.indexOf(root)) fragment = fragment.substr(root.length); - } else { - fragment = this.getHash(); - } - } - return fragment.replace(routeStripper, ''); - }, - - // Start the hash change handling, returning `true` if the current URL matches - // an existing route, and `false` otherwise. - start: function(options) { - if (History.started) throw new Error("Backbone.history has already been started"); - History.started = true; - - // Figure out the initial configuration. Do we need an iframe? - // Is pushState desired ... is it available? - this.options = _.extend({}, {root: '/'}, this.options, options); - this.root = this.options.root; - this._wantsHashChange = this.options.hashChange !== false; - this._wantsPushState = !!this.options.pushState; - this._hasPushState = !!(this.options.pushState && this.history && this.history.pushState); - var fragment = this.getFragment(); - var docMode = document.documentMode; - var oldIE = (isExplorer.exec(navigator.userAgent.toLowerCase()) && (!docMode || docMode <= 7)); - - // Normalize root to always include a leading and trailing slash. - this.root = ('/' + this.root + '/').replace(rootStripper, '/'); - - if (oldIE && this._wantsHashChange) { - this.iframe = Backbone.$('