diff --git a/background.html b/background.html index 7efe63e1..61440858 100644 --- a/background.html +++ b/background.html @@ -16,6 +16,7 @@ + @@ -29,7 +30,7 @@ - + diff --git a/bower.json b/bower.json index b15802e7..5f4a96c1 100644 --- a/bower.json +++ b/bower.json @@ -16,7 +16,8 @@ "cryptojs": "svn+http://crypto-js.googlecode.com/svn/#~3.1.2", "libphonenumber-api": "git://github.com/codedust/libphonenumber-api", "backbone.localstorage": "liliakai/Backbone.localStorage#master", - "momentjs": "~2.8.3" + "momentjs": "~2.8.3", + "indexeddb-backbonejs-adapter": "*" }, "devDependencies": { "mocha": "~2.0.1", @@ -79,6 +80,9 @@ ], "momentjs": [ "moment.js" + ], + "indexeddb-backbonejs-adapter": [ + "backbone-indexeddb.js" ] }, "concat": { @@ -90,7 +94,7 @@ "mustache", "underscore", "backbone", - "backbone.localstorage", + "indexeddb-backbonejs-adapter", "qrcode", "libphonenumber-api", "momentjs", diff --git a/components/indexeddb-backbonejs-adapter/backbone-indexeddb.js b/components/indexeddb-backbonejs-adapter/backbone-indexeddb.js new file mode 100644 index 00000000..36735091 --- /dev/null +++ b/components/indexeddb-backbonejs-adapter/backbone-indexeddb.js @@ -0,0 +1,625 @@ +(function (root, factory) { + if (typeof define === 'function' && define.amd) { + // AMD. Register as an anonymous module. + define(['backbone', 'underscore'], factory); + } else if (typeof exports === 'object') { + // Node. Does not work with strict CommonJS, but + // only CommonJS-like environments that support module.exports, + // like Node. + module.exports = factory(require('backbone'), require('underscore')); + } else { + // Browser globals (root is window) + root.returnExports = factory(root.Backbone, root._); + } +}(this, function (Backbone, _) { + + // Generate four random hex digits. + function S4() { + return (((1 + Math.random()) * 0x10000) | 0).toString(16).substring(1); + } + + // Generate a pseudo-GUID by concatenating random hexadecimal. + function guid() { + return (S4() + S4() + "-" + S4() + "-" + S4() + "-" + S4() + "-" + S4() + S4() + S4()); + } + + if ( _(indexedDB).isUndefined() ) { return; } + + // Driver object + // That's the interesting part. + // There is a driver for each schema provided. The schema is a te combination of name (for the database), a version as well as migrations to reach that + // version of the database. + function Driver(schema, ready, nolog, onerror) { + this.schema = schema; + this.ready = ready; + this.error = null; + this.transactions = []; // Used to list all transactions and keep track of active ones. + this.db = null; + this.nolog = nolog; + this.onerror = onerror; + var lastMigrationPathVersion = _.last(this.schema.migrations).version; + if (!this.nolog) debugLog("opening database " + this.schema.id + " in version #" + lastMigrationPathVersion); + this.dbRequest = indexedDB.open(this.schema.id,lastMigrationPathVersion); //schema version need to be an unsigned long + + this.launchMigrationPath = function(dbVersion) { + var transaction = this.dbRequest.transaction; + var clonedMigrations = _.clone(schema.migrations); + this.migrate(transaction, clonedMigrations, dbVersion, { + error: function (event) { + this.error = "Database not up to date. " + dbVersion + " expected was " + lastMigrationPathVersion; + }.bind(this) + }); + }; + + this.dbRequest.onblocked = function(event){ + if (!this.nolog) debugLog("connection to database blocked"); + } + + this.dbRequest.onsuccess = function (e) { + this.db = e.target.result; // Attach the connection ot the queue. + var currentIntDBVersion = (parseInt(this.db.version) || 0); // we need convert beacuse chrome store in integer and ie10 DP4+ in int; + var lastMigrationInt = (parseInt(lastMigrationPathVersion) || 0); // And make sure we compare numbers with numbers. + + if (currentIntDBVersion === lastMigrationInt) { //if support new event onupgradeneeded will trigger the ready function + // No migration to perform! + this.ready(); + } else if (currentIntDBVersion < lastMigrationInt ) { + // We need to migrate up to the current migration defined in the database + this.launchMigrationPath(currentIntDBVersion); + } else { + // Looks like the IndexedDB is at a higher version than the current driver schema. + this.error = "Database version is greater than current code " + currentIntDBVersion + " expected was " + lastMigrationInt; + } + }.bind(this); + + + + this.dbRequest.onerror = function (e) { + // Failed to open the database + this.error = "Couldn't not connect to the database" + if (!this.nolog) debugLog("Couldn't not connect to the database"); + this.onerror(); + }.bind(this); + + this.dbRequest.onabort = function (e) { + // Failed to open the database + this.error = "Connection to the database aborted" + if (!this.nolog) debugLog("Connection to the database aborted"); + this.onerror(); + }.bind(this); + + + + this.dbRequest.onupgradeneeded = function(iDBVersionChangeEvent){ + this.db =iDBVersionChangeEvent.target.result; + + var newVersion = iDBVersionChangeEvent.newVersion; + var oldVersion = iDBVersionChangeEvent.oldVersion; + + // Fix Safari 8 and iOS 8 bug + // at the first connection oldVersion is equal to 9223372036854776000 + // but the real value is 0 + if (oldVersion > 99999999999) + oldVersion = 0; + + if (!this.nolog) debugLog("onupgradeneeded = " + oldVersion + " => " + newVersion); + this.launchMigrationPath(oldVersion); + }.bind(this); + } + + function debugLog(str) { + if (typeof window !== "undefined" && typeof window.console !== "undefined" && typeof window.console.log !== "undefined") { + window.console.log(str); + } + else if(console.log !== "undefined") { + console.log(str) + } + } + + // Driver Prototype + Driver.prototype = { + + // Tracks transactions. Mostly for debugging purposes. TO-IMPROVE + _track_transaction: function(transaction) { + this.transactions.push(transaction); + function removeIt() { + var idx = this.transactions.indexOf(transaction); + if (idx !== -1) {this.transactions.splice(idx); } + }; + transaction.oncomplete = removeIt.bind(this); + transaction.onabort = removeIt.bind(this); + transaction.onerror = removeIt.bind(this); + }, + + // Performs all the migrations to reach the right version of the database. + migrate: function (transaction, migrations, version, options) { + transaction.onerror = options.error; + transaction.onabort = options.error; + + if (!this.nolog) debugLog("migrate begin version from #" + version); + var that = this; + var migration = migrations.shift(); + if (migration) { + if (!version || version < migration.version) { + // We need to apply this migration- + if (typeof migration.before == "undefined") { + migration.before = function (next) { + next(); + }; + } + if (typeof migration.after == "undefined") { + migration.after = function (next) { + next(); + }; + } + // First, let's run the before script + if (!this.nolog) debugLog("migrate begin before version #" + migration.version); + migration.before(function () { + if (!this.nolog) debugLog("migrate done before version #" + migration.version); + + if (!this.nolog) debugLog("migrate begin migrate version #" + migration.version); + + migration.migrate(transaction, function () { + if (!this.nolog) debugLog("migrate done migrate version #" + migration.version); + // Migration successfully appliedn let's go to the next one! + if (!this.nolog) debugLog("migrate begin after version #" + migration.version); + migration.after(function () { + if (!this.nolog) debugLog("migrate done after version #" + migration.version); + if (!this.nolog) debugLog("Migrated to " + migration.version); + + //last modification occurred, need finish + if(migrations.length ==0) { + if (!this.nolog) { + debugLog("migrate setting transaction.oncomplete to finish version #" + migration.version); + transaction.oncomplete = function() { + debugLog("migrate done transaction.oncomplete version #" + migration.version); + debugLog("Done migrating"); + } + } + } + else + { + if (!this.nolog) debugLog("migrate end from version #" + version + " to " + migration.version); + that.migrate(transaction, migrations, version, options); + } + + }.bind(this)); + }.bind(this)); + }.bind(this)); + } else { + // No need to apply this migration + if (!this.nolog) debugLog("Skipping migration " + migration.version); + this.migrate(transaction, migrations, version, options); + } + } + }, + + // This is the main method, called by the ExecutionQueue when the driver is ready (database open and migration performed) + execute: function (storeName, method, object, options) { + if (!this.nolog) debugLog("execute : " + method + " on " + storeName + " for " + object.id); + switch (method) { + case "create": + this.create(storeName, object, options); + break; + case "read": + if (object.id || object.cid) { + this.read(storeName, object, options); // It's a model + } else { + this.query(storeName, object, options); // It's a collection + } + break; + case "update": + this.update(storeName, object, options); // We may want to check that this is not a collection. TOFIX + break; + case "delete": + if (object.id || object.cid) { + this.delete(storeName, object, options); + } else { + this.clear(storeName, object, options); + } + break; + default: + // Hum what? + } + }, + + // Writes the json to the storeName in db. It is a create operations, which means it will fail if the key already exists + // options are just success and error callbacks. + create: function (storeName, object, options) { + var writeTransaction = this.db.transaction([storeName], 'readwrite'); + //this._track_transaction(writeTransaction); + var store = writeTransaction.objectStore(storeName); + var json = object.toJSON(); + var idAttribute = _.result(object, 'idAttribute'); + var writeRequest; + + if (json[idAttribute] === undefined && !store.autoIncrement) json[idAttribute] = guid(); + + writeTransaction.onerror = function (e) { + options.error(e); + }; + writeTransaction.oncomplete = function (e) { + options.success(json); + }; + + if (!store.keyPath) + writeRequest = store.add(json, json[idAttribute]); + else + writeRequest = store.add(json); + }, + + // Writes the json to the storeName in db. It is an update operation, which means it will overwrite the value if the key already exist + // options are just success and error callbacks. + update: function (storeName, object, options) { + var writeTransaction = this.db.transaction([storeName], 'readwrite'); + //this._track_transaction(writeTransaction); + var store = writeTransaction.objectStore(storeName); + var json = object.toJSON(); + var idAttribute = _.result(object, 'idAttribute'); + var writeRequest; + + if (!json[idAttribute]) json[idAttribute] = guid(); + + if (!store.keyPath) + writeRequest = store.put(json, json[idAttribute]); + else + writeRequest = store.put(json); + + writeRequest.onerror = function (e) { + options.error(e); + }; + writeTransaction.oncomplete = function (e) { + options.success(json); + }; + }, + + // Reads from storeName in db with json.id if it's there of with any json.xxxx as long as xxx is an index in storeName + read: function (storeName, object, options) { + var readTransaction = this.db.transaction([storeName], "readonly"); + this._track_transaction(readTransaction); + + var store = readTransaction.objectStore(storeName); + var json = object.toJSON(); + var idAttribute = _.result(object, 'idAttribute'); + + var getRequest = null; + if (json[idAttribute]) { + getRequest = store.get(json[idAttribute]); + } else if(options.index) { + var index = store.index(options.index.name); + getRequest = index.get(options.index.value); + } else { + // We need to find which index we have + var cardinality = 0; // try to fit the index with most matches + _.each(store.indexNames, function (key, index) { + index = store.index(key); + if(typeof index.keyPath === 'string' && 1 > cardinality) { + // simple index + if (json[index.keyPath] !== undefined) { + getRequest = index.get(json[index.keyPath]); + cardinality = 1; + } + } else if(typeof index.keyPath === 'object' && index.keyPath.length > cardinality) { + // compound index + var valid = true; + var keyValue = _.map(index.keyPath, function(keyPart) { + valid = valid && json[keyPart] !== undefined; + return json[keyPart]; + }); + if(valid) { + getRequest = index.get(keyValue); + cardinality = index.keyPath.length; + } + } + }); + } + if (getRequest) { + getRequest.onsuccess = function (event) { + if (event.target.result) { + options.success(event.target.result); + } else { + options.error("Not Found"); + } + }; + getRequest.onerror = function () { + options.error("Not Found"); // We couldn't find the record. + } + } else { + options.error("Not Found"); // We couldn't even look for it, as we don't have enough data. + } + }, + + // Deletes the json.id key and value in storeName from db. + delete: function (storeName, object, options) { + var deleteTransaction = this.db.transaction([storeName], 'readwrite'); + //this._track_transaction(deleteTransaction); + + var store = deleteTransaction.objectStore(storeName); + var json = object.toJSON(); + var idAttribute = _.result(object, 'idAttribute'); + + var deleteRequest = store.delete(json[idAttribute]); + + deleteTransaction.oncomplete = function (event) { + options.success(null); + }; + deleteRequest.onerror = function (event) { + options.error("Not Deleted"); + }; + }, + + // Clears all records for storeName from db. + clear: function (storeName, object, options) { + var deleteTransaction = this.db.transaction([storeName], "readwrite"); + //this._track_transaction(deleteTransaction); + + var store = deleteTransaction.objectStore(storeName); + + var deleteRequest = store.clear(); + deleteRequest.onsuccess = function (event) { + options.success(null); + }; + deleteRequest.onerror = function (event) { + options.error("Not Cleared"); + }; + }, + + // Performs a query on storeName in db. + // options may include : + // - conditions : value of an index, or range for an index + // - range : range for the primary key + // - limit : max number of elements to be yielded + // - offset : skipped items. + query: function (storeName, collection, options) { + var elements = []; + var skipped = 0, processed = 0; + var queryTransaction = this.db.transaction([storeName], "readonly"); + //this._track_transaction(queryTransaction); + + var idAttribute = _.result(collection.model.prototype, 'idAttribute'); + var readCursor = null; + var store = queryTransaction.objectStore(storeName); + var index = null, + lower = null, + upper = null, + bounds = null; + + if (options.conditions) { + // We have a condition, we need to use it for the cursor + _.each(store.indexNames, function (key) { + if (!readCursor) { + index = store.index(key); + if (options.conditions[index.keyPath] instanceof Array) { + lower = options.conditions[index.keyPath][0] > options.conditions[index.keyPath][1] ? options.conditions[index.keyPath][1] : options.conditions[index.keyPath][0]; + upper = options.conditions[index.keyPath][0] > options.conditions[index.keyPath][1] ? options.conditions[index.keyPath][0] : options.conditions[index.keyPath][1]; + bounds = IDBKeyRange.bound(lower, upper, true, true); + + if (options.conditions[index.keyPath][0] > options.conditions[index.keyPath][1]) { + // Looks like we want the DESC order + readCursor = index.openCursor(bounds, window.IDBCursor.PREV || "prev"); + } else { + // We want ASC order + readCursor = index.openCursor(bounds, window.IDBCursor.NEXT || "next"); + } + } else if (typeof options.conditions[index.keyPath] === 'object' && ('$gt' in options.conditions[index.keyPath] || '$gte' in options.conditions[index.keyPath])) { + if('$gt' in options.conditions[index.keyPath]) + bounds = IDBKeyRange.lowerBound(options.conditions[index.keyPath]['$gt'], true); + else + bounds = IDBKeyRange.lowerBound(options.conditions[index.keyPath]['$gte']); + readCursor = index.openCursor(bounds, window.IDBCursor.NEXT || "next"); + } else if (typeof options.conditions[index.keyPath] === 'object' && ('$lt' in options.conditions[index.keyPath] || '$lte' in options.conditions[index.keyPath])) { + if('$lt' in options.conditions[index.keyPath]) + bounds = IDBKeyRange.upperBound(options.conditions[index.keyPath]['$lt'], true); + else + bounds = IDBKeyRange.upperBound(options.conditions[index.keyPath]['$lte']); + readCursor = index.openCursor(bounds, window.IDBCursor.NEXT || "next"); + } else if (options.conditions[index.keyPath] != undefined) { + bounds = IDBKeyRange.only(options.conditions[index.keyPath]); + readCursor = index.openCursor(bounds); + } + } + }); + } else { + // No conditions, use the index + if (options.range) { + lower = options.range[0] > options.range[1] ? options.range[1] : options.range[0]; + upper = options.range[0] > options.range[1] ? options.range[0] : options.range[1]; + bounds = IDBKeyRange.bound(lower, upper); + if (options.range[0] > options.range[1]) { + readCursor = store.openCursor(bounds, window.IDBCursor.PREV || "prev"); + } else { + readCursor = store.openCursor(bounds, window.IDBCursor.NEXT || "next"); + } + } else { + readCursor = store.openCursor(); + } + } + + if (typeof (readCursor) == "undefined" || !readCursor) { + options.error("No Cursor"); + } else { + readCursor.onerror = function(e){ + options.error("readCursor error", e); + }; + // Setup a handler for the cursor’s `success` event: + readCursor.onsuccess = function (e) { + var cursor = e.target.result; + if (!cursor) { + if (options.addIndividually || options.clear) { + // nothing! + // We need to indicate that we're done. But, how? + collection.trigger("reset"); + } else { + options.success(elements); // We're done. No more elements. + } + } + else { + // Cursor is not over yet. + if (options.limit && processed >= options.limit) { + // Yet, we have processed enough elements. So, let's just skip. + if (bounds && options.conditions[index.keyPath]) { + cursor.continue(options.conditions[index.keyPath][1] + 1); /* We need to 'terminate' the cursor cleany, by moving to the end */ + } else { + cursor.continue(); /* We need to 'terminate' the cursor cleany, by moving to the end */ + } + } + else if (options.offset && options.offset > skipped) { + skipped++; + cursor.continue(); /* We need to Moving the cursor forward */ + } else { + // This time, it looks like it's good! + if (options.addIndividually) { + collection.add(cursor.value); + } else if (options.clear) { + var deleteRequest = store.delete(cursor.value[idAttribute]); + deleteRequest.onsuccess = function (event) { + elements.push(cursor.value); + }; + deleteRequest.onerror = function (event) { + elements.push(cursor.value); + }; + + } else { + elements.push(cursor.value); + } + processed++; + cursor.continue(); + } + } + }; + } + }, + close :function(){ + if(this.db){ + this.db.close(); + } + } + }; + + // ExecutionQueue object + // The execution queue is an abstraction to buffer up requests to the database. + // It holds a "driver". When the driver is ready, it just fires up the queue and executes in sync. + function ExecutionQueue(schema,next,nolog) { + this.driver = new Driver(schema, this.ready.bind(this), nolog, this.error.bind(this)); + this.started = false; + this.failed = false; + this.stack = []; + this.version = _.last(schema.migrations).version; + this.next = next; + } + + // ExecutionQueue Prototype + ExecutionQueue.prototype = { + // Called when the driver is ready + // It just loops over the elements in the queue and executes them. + ready: function () { + this.started = true; + _.each(this.stack, function (message) { + this.execute(message); + }.bind(this)); + this.stack = []; // fix memory leak + this.next(); + }, + + error: function() { + this.failed = true; + _.each(this.stack, function (message) { + this.execute(message); + }.bind(this)); + this.stack = []; + this.next(); + }, + + // Executes a given command on the driver. If not started, just stacks up one more element. + execute: function (message) { + if (this.started) { + this.driver.execute(message[2].storeName || message[1].storeName, message[0], message[1], message[2]); // Upon messages, we execute the query + } else if (this.failed) { + message[2].error(); + } else { + this.stack.push(message); + } + }, + + close : function(){ + this.driver.close(); + } + }; + + // Method used by Backbone for sync of data with data store. It was initially designed to work with "server side" APIs, This wrapper makes + // it work with the local indexedDB stuff. It uses the schema attribute provided by the object. + // The wrapper keeps an active Executuon Queue for each "schema", and executes querues agains it, based on the object type (collection or + // single model), but also the method... etc. + // Keeps track of the connections + var Databases = {}; + + function sync(method, object, options) { + + if(method == "closeall"){ + _.each(Databases,function(database){ + database.close(); + }); + // Clean up active databases object. + Databases = {}; + return Backbone.$.Deferred().resolve(); + } + + // If a model or a collection does not define a database, fall back on ajaxSync + if (!object || !_.isObject(object.database)) { + return Backbone.ajaxSync(method, object, options); + } + + var schema = object.database; + if (Databases[schema.id]) { + if(Databases[schema.id].version != _.last(schema.migrations).version){ + Databases[schema.id].close(); + delete Databases[schema.id]; + } + } + + var promise; + + if (typeof Backbone.$ === 'undefined' || typeof Backbone.$.Deferred === 'undefined') { + var noop = function() {}; + var resolve = noop; + var reject = noop; + } else { + var dfd = Backbone.$.Deferred(); + var resolve = dfd.resolve; + var reject = dfd.reject; + + promise = dfd.promise(); + } + + var success = options.success; + options.success = function(resp) { + if (success) success(resp); + resolve(); + object.trigger('sync', object, resp, options); + }; + + var error = options.error; + options.error = function(resp) { + if (error) error(resp); + reject(); + object.trigger('error', object, resp, options); + }; + + var next = function(){ + Databases[schema.id].execute([method, object, options]); + }; + + if (!Databases[schema.id]) { + Databases[schema.id] = new ExecutionQueue(schema,next,schema.nolog); + } else { + next(); + } + + return promise; + }; + + Backbone.ajaxSync = Backbone.sync; + Backbone.sync = sync; + + return { sync: sync, debugLog: debugLog}; +})); diff --git a/index.html b/index.html index c1564708..9566067e 100644 --- a/index.html +++ b/index.html @@ -126,6 +126,7 @@ + @@ -143,7 +144,7 @@ - + diff --git a/js/background.js b/js/background.js index 1595971a..cb533395 100644 --- a/js/background.js +++ b/js/background.js @@ -23,8 +23,9 @@ extension.navigator.tabs.create("options.html"); } else { if (textsecure.registration.isDone()) { + var conversations = new Whisper.ConversationCollection(); textsecure.subscribeToPush(function(message) { - Whisper.Threads.addIncomingMessage(message); + conversations.addIncomingMessage(message); console.log("Got message from " + message.pushMessage.source + "." + message.pushMessage.sourceDevice + ': "' + getString(message.message.body) + '"'); var newUnreadCount = textsecure.storage.getUnencrypted("unreadCount", 0) + 1; diff --git a/js/components.js b/js/components.js index f00f467e..74b0f736 100644 --- a/js/components.js +++ b/js/components.js @@ -21356,267 +21356,630 @@ return jQuery; })); -/** - * Backbone localStorage Adapter - * Version 1.1.14 - * - * https://github.com/jeromegn/Backbone.localStorage - */ (function (root, factory) { - if (typeof exports === 'object' && typeof require === 'function') { - module.exports = factory(require("backbone")); - } else if (typeof define === "function" && define.amd) { - // AMD. Register as an anonymous module. - define(["backbone"], function(Backbone) { - // Use global variables if the locals are undefined. - return factory(Backbone || root.Backbone); - }); - } else { - factory(Backbone); - } -}(this, function(Backbone) { -// A simple module to replace `Backbone.sync` with *localStorage*-based -// persistence. Models are given GUIDS, and saved into a JSON object. Simple -// as that. - -// Generate four random hex digits. -function S4() { - return (((1+Math.random())*0x10000)|0).toString(16).substring(1); -}; - -// Generate a pseudo-GUID by concatenating random hexadecimal. -function guid() { - return (S4()+S4()+"-"+S4()+"-"+S4()+"-"+S4()+"-"+S4()+S4()+S4()); -}; - -function isObject(item) { - return item === Object(item); -} - -function contains(array, item) { - var i = array.length; - while (i--) if (array[i] === item) return true; - return false; -} - -function extend(obj, props) { - for (var key in props) obj[key] = props[key] - return obj; -} - -function result(object, property) { - if (object == null) return void 0; - var value = object[property]; - return (typeof value === 'function') ? object[property]() : value; -} - -// Our Store is represented by a single JS object in *localStorage*. Create it -// with a meaningful name, like the name you'd give a table. -// window.Store is deprectated, use Backbone.LocalStorage instead -Backbone.LocalStorage = window.Store = function(name, serializer) { - if( !this.localStorage ) { - throw "Backbone.localStorage: Environment does not support localStorage." - } - this.name = name; - this.serializer = serializer || { - serialize: function(item) { - return isObject(item) ? JSON.stringify(item) : item; - }, - // fix for "illegal access" error on Android when JSON.parse is passed null - deserialize: function (data) { - return data && JSON.parse(data); + if (typeof define === 'function' && define.amd) { + // AMD. Register as an anonymous module. + define(['backbone', 'underscore'], factory); + } else if (typeof exports === 'object') { + // Node. Does not work with strict CommonJS, but + // only CommonJS-like environments that support module.exports, + // like Node. + module.exports = factory(require('backbone'), require('underscore')); + } else { + // Browser globals (root is window) + root.returnExports = factory(root.Backbone, root._); } - }; - var store = this.localStorage().getItem(this.name); - this.records = (store && store.split(",")) || []; -}; +}(this, function (Backbone, _) { -extend(Backbone.LocalStorage.prototype, { - - // Save the current state of the **Store** to *localStorage*. - save: function() { - var store = this.localStorage().getItem(this.name); - this.records = _.union(this.records, store && store.split(",")); - this.localStorage().setItem(this.name, this.records.join(",")); - }, - - // Add a model, giving it a (hopefully)-unique GUID, if it doesn't already - // have an id of it's own. - create: function(model) { - if (!model.id && model.id !== 0) { - model.id = guid(); - model.set(model.idAttribute, model.id); - } - this.localStorage().setItem(this._itemName(model.id), this.serializer.serialize(model)); - this.records.push(model.id.toString()); - this.save(); - return this.find(model); - }, - - // Update a model by replacing its copy in `this.data`. - update: function(model) { - this.localStorage().setItem(this._itemName(model.id), this.serializer.serialize(model)); - var modelId = model.id.toString(); - if (!contains(this.records, modelId)) { - this.records.push(modelId); - this.save(); - } - return this.find(model); - }, - - // Retrieve a model from `this.data` by id. - find: function(model) { - var store = this.localStorage().getItem(this.name); - this.records = (store && store.split(",")) || []; - return this.serializer.deserialize(this.localStorage().getItem(this._itemName(model.id))); - }, - - // Return the array of all models currently in storage. - findAll: function() { - var store = this.localStorage().getItem(this.name); - this.records = (store && store.split(",")) || []; - var result = []; - for (var i = 0, id, data; i < this.records.length; i++) { - id = this.records[i]; - data = this.serializer.deserialize(this.localStorage().getItem(this._itemName(id))); - if (data != null) result.push(data); - } - return result; - }, - - // Delete a model from `this.data`, returning it. - destroy: function(model) { - this.localStorage().removeItem(this._itemName(model.id)); - var modelId = model.id.toString(); - for (var i = 0, id; i < this.records.length; i++) { - if (this.records[i] === modelId) { - this.records.splice(i, 1); - } - } - this.save(); - return model; - }, - - localStorage: function() { - return localStorage; - }, - - // Clear localStorage for specific collection. - _clear: function() { - var local = this.localStorage(), - itemRe = new RegExp("^" + this.name + "-"); - - // Remove id-tracking item (e.g., "foo"). - local.removeItem(this.name); - - // Match all data items (e.g., "foo-ID") and remove. - for (var k in local) { - if (itemRe.test(k)) { - local.removeItem(k); - } + // Generate four random hex digits. + function S4() { + return (((1 + Math.random()) * 0x10000) | 0).toString(16).substring(1); } - this.records.length = 0; - }, - - // Size of localStorage. - _storageSize: function() { - return this.localStorage().length; - }, - - _itemName: function(id) { - return this.name+"-"+id; - } - -}); - -// localSync delegate to the model or collection's -// *localStorage* property, which should be an instance of `Store`. -// window.Store.sync and Backbone.localSync is deprecated, use Backbone.LocalStorage.sync instead -Backbone.LocalStorage.sync = window.Store.sync = Backbone.localSync = function(method, model, options) { - var store = result(model, 'localStorage') || result(model.collection, 'localStorage'); - - var resp, errorMessage; - //If $ is having Deferred - use it. - var syncDfd = Backbone.$ ? - (Backbone.$.Deferred && Backbone.$.Deferred()) : - (Backbone.Deferred && Backbone.Deferred()); - - try { - - switch (method) { - case "read": - resp = model.id != undefined ? store.find(model) : store.findAll(); - break; - case "create": - resp = store.create(model); - break; - case "update": - resp = store.update(model); - break; - case "delete": - resp = store.destroy(model); - break; + // Generate a pseudo-GUID by concatenating random hexadecimal. + function guid() { + return (S4() + S4() + "-" + S4() + "-" + S4() + "-" + S4() + "-" + S4() + S4() + S4()); } - } catch(error) { - if (error.code === 22 && store._storageSize() === 0) - errorMessage = "Private browsing is unsupported"; - else - errorMessage = error.message; - } + if ( _(indexedDB).isUndefined() ) { return; } - if (resp) { - if (options && options.success) { - if (Backbone.VERSION === "0.9.10") { - options.success(model, resp, options); - } else { - options.success(resp); - } - } - if (syncDfd) { - syncDfd.resolve(resp); + // Driver object + // That's the interesting part. + // There is a driver for each schema provided. The schema is a te combination of name (for the database), a version as well as migrations to reach that + // version of the database. + function Driver(schema, ready, nolog, onerror) { + this.schema = schema; + this.ready = ready; + this.error = null; + this.transactions = []; // Used to list all transactions and keep track of active ones. + this.db = null; + this.nolog = nolog; + this.onerror = onerror; + var lastMigrationPathVersion = _.last(this.schema.migrations).version; + if (!this.nolog) debugLog("opening database " + this.schema.id + " in version #" + lastMigrationPathVersion); + this.dbRequest = indexedDB.open(this.schema.id,lastMigrationPathVersion); //schema version need to be an unsigned long + + this.launchMigrationPath = function(dbVersion) { + var transaction = this.dbRequest.transaction; + var clonedMigrations = _.clone(schema.migrations); + this.migrate(transaction, clonedMigrations, dbVersion, { + error: function (event) { + this.error = "Database not up to date. " + dbVersion + " expected was " + lastMigrationPathVersion; + }.bind(this) + }); + }; + + this.dbRequest.onblocked = function(event){ + if (!this.nolog) debugLog("connection to database blocked"); + } + + this.dbRequest.onsuccess = function (e) { + this.db = e.target.result; // Attach the connection ot the queue. + var currentIntDBVersion = (parseInt(this.db.version) || 0); // we need convert beacuse chrome store in integer and ie10 DP4+ in int; + var lastMigrationInt = (parseInt(lastMigrationPathVersion) || 0); // And make sure we compare numbers with numbers. + + if (currentIntDBVersion === lastMigrationInt) { //if support new event onupgradeneeded will trigger the ready function + // No migration to perform! + this.ready(); + } else if (currentIntDBVersion < lastMigrationInt ) { + // We need to migrate up to the current migration defined in the database + this.launchMigrationPath(currentIntDBVersion); + } else { + // Looks like the IndexedDB is at a higher version than the current driver schema. + this.error = "Database version is greater than current code " + currentIntDBVersion + " expected was " + lastMigrationInt; + } + }.bind(this); + + + + this.dbRequest.onerror = function (e) { + // Failed to open the database + this.error = "Couldn't not connect to the database" + if (!this.nolog) debugLog("Couldn't not connect to the database"); + this.onerror(); + }.bind(this); + + this.dbRequest.onabort = function (e) { + // Failed to open the database + this.error = "Connection to the database aborted" + if (!this.nolog) debugLog("Connection to the database aborted"); + this.onerror(); + }.bind(this); + + + + this.dbRequest.onupgradeneeded = function(iDBVersionChangeEvent){ + this.db =iDBVersionChangeEvent.target.result; + + var newVersion = iDBVersionChangeEvent.newVersion; + var oldVersion = iDBVersionChangeEvent.oldVersion; + + // Fix Safari 8 and iOS 8 bug + // at the first connection oldVersion is equal to 9223372036854776000 + // but the real value is 0 + if (oldVersion > 99999999999) + oldVersion = 0; + + if (!this.nolog) debugLog("onupgradeneeded = " + oldVersion + " => " + newVersion); + this.launchMigrationPath(oldVersion); + }.bind(this); } - } else { - errorMessage = errorMessage ? errorMessage - : "Record Not Found"; + function debugLog(str) { + if (typeof window !== "undefined" && typeof window.console !== "undefined" && typeof window.console.log !== "undefined") { + window.console.log(str); + } + else if(console.log !== "undefined") { + console.log(str) + } + } - if (options && options.error) - if (Backbone.VERSION === "0.9.10") { - options.error(model, errorMessage, options); - } else { - options.error(errorMessage); - } + // Driver Prototype + Driver.prototype = { - if (syncDfd) - syncDfd.reject(errorMessage); - } + // Tracks transactions. Mostly for debugging purposes. TO-IMPROVE + _track_transaction: function(transaction) { + this.transactions.push(transaction); + function removeIt() { + var idx = this.transactions.indexOf(transaction); + if (idx !== -1) {this.transactions.splice(idx); } + }; + transaction.oncomplete = removeIt.bind(this); + transaction.onabort = removeIt.bind(this); + transaction.onerror = removeIt.bind(this); + }, - // add compatibility with $.ajax - // always execute callback for success and error - if (options && options.complete) options.complete(resp); + // Performs all the migrations to reach the right version of the database. + migrate: function (transaction, migrations, version, options) { + transaction.onerror = options.error; + transaction.onabort = options.error; - return syncDfd && syncDfd.promise(); -}; + if (!this.nolog) debugLog("migrate begin version from #" + version); + var that = this; + var migration = migrations.shift(); + if (migration) { + if (!version || version < migration.version) { + // We need to apply this migration- + if (typeof migration.before == "undefined") { + migration.before = function (next) { + next(); + }; + } + if (typeof migration.after == "undefined") { + migration.after = function (next) { + next(); + }; + } + // First, let's run the before script + if (!this.nolog) debugLog("migrate begin before version #" + migration.version); + migration.before(function () { + if (!this.nolog) debugLog("migrate done before version #" + migration.version); -Backbone.ajaxSync = Backbone.sync; + if (!this.nolog) debugLog("migrate begin migrate version #" + migration.version); -Backbone.getSyncMethod = function(model) { - if(model.localStorage || (model.collection && model.collection.localStorage)) { - return Backbone.localSync; - } + migration.migrate(transaction, function () { + if (!this.nolog) debugLog("migrate done migrate version #" + migration.version); + // Migration successfully appliedn let's go to the next one! + if (!this.nolog) debugLog("migrate begin after version #" + migration.version); + migration.after(function () { + if (!this.nolog) debugLog("migrate done after version #" + migration.version); + if (!this.nolog) debugLog("Migrated to " + migration.version); - return Backbone.ajaxSync; -}; + //last modification occurred, need finish + if(migrations.length ==0) { + if (!this.nolog) { + debugLog("migrate setting transaction.oncomplete to finish version #" + migration.version); + transaction.oncomplete = function() { + debugLog("migrate done transaction.oncomplete version #" + migration.version); + debugLog("Done migrating"); + } + } + } + else + { + if (!this.nolog) debugLog("migrate end from version #" + version + " to " + migration.version); + that.migrate(transaction, migrations, version, options); + } -// Override 'Backbone.sync' to default to localSync, -// the original 'Backbone.sync' is still available in 'Backbone.ajaxSync' -Backbone.sync = function(method, model, options) { - return Backbone.getSyncMethod(model).apply(this, [method, model, options]); -}; + }.bind(this)); + }.bind(this)); + }.bind(this)); + } else { + // No need to apply this migration + if (!this.nolog) debugLog("Skipping migration " + migration.version); + this.migrate(transaction, migrations, version, options); + } + } + }, -return Backbone.LocalStorage; + // This is the main method, called by the ExecutionQueue when the driver is ready (database open and migration performed) + execute: function (storeName, method, object, options) { + if (!this.nolog) debugLog("execute : " + method + " on " + storeName + " for " + object.id); + switch (method) { + case "create": + this.create(storeName, object, options); + break; + case "read": + if (object.id || object.cid) { + this.read(storeName, object, options); // It's a model + } else { + this.query(storeName, object, options); // It's a collection + } + break; + case "update": + this.update(storeName, object, options); // We may want to check that this is not a collection. TOFIX + break; + case "delete": + if (object.id || object.cid) { + this.delete(storeName, object, options); + } else { + this.clear(storeName, object, options); + } + break; + default: + // Hum what? + } + }, + + // Writes the json to the storeName in db. It is a create operations, which means it will fail if the key already exists + // options are just success and error callbacks. + create: function (storeName, object, options) { + var writeTransaction = this.db.transaction([storeName], 'readwrite'); + //this._track_transaction(writeTransaction); + var store = writeTransaction.objectStore(storeName); + var json = object.toJSON(); + var idAttribute = _.result(object, 'idAttribute'); + var writeRequest; + + if (json[idAttribute] === undefined && !store.autoIncrement) json[idAttribute] = guid(); + + writeTransaction.onerror = function (e) { + options.error(e); + }; + writeTransaction.oncomplete = function (e) { + options.success(json); + }; + + if (!store.keyPath) + writeRequest = store.add(json, json[idAttribute]); + else + writeRequest = store.add(json); + }, + + // Writes the json to the storeName in db. It is an update operation, which means it will overwrite the value if the key already exist + // options are just success and error callbacks. + update: function (storeName, object, options) { + var writeTransaction = this.db.transaction([storeName], 'readwrite'); + //this._track_transaction(writeTransaction); + var store = writeTransaction.objectStore(storeName); + var json = object.toJSON(); + var idAttribute = _.result(object, 'idAttribute'); + var writeRequest; + + if (!json[idAttribute]) json[idAttribute] = guid(); + + if (!store.keyPath) + writeRequest = store.put(json, json[idAttribute]); + else + writeRequest = store.put(json); + + writeRequest.onerror = function (e) { + options.error(e); + }; + writeTransaction.oncomplete = function (e) { + options.success(json); + }; + }, + + // Reads from storeName in db with json.id if it's there of with any json.xxxx as long as xxx is an index in storeName + read: function (storeName, object, options) { + var readTransaction = this.db.transaction([storeName], "readonly"); + this._track_transaction(readTransaction); + + var store = readTransaction.objectStore(storeName); + var json = object.toJSON(); + var idAttribute = _.result(object, 'idAttribute'); + + var getRequest = null; + if (json[idAttribute]) { + getRequest = store.get(json[idAttribute]); + } else if(options.index) { + var index = store.index(options.index.name); + getRequest = index.get(options.index.value); + } else { + // We need to find which index we have + var cardinality = 0; // try to fit the index with most matches + _.each(store.indexNames, function (key, index) { + index = store.index(key); + if(typeof index.keyPath === 'string' && 1 > cardinality) { + // simple index + if (json[index.keyPath] !== undefined) { + getRequest = index.get(json[index.keyPath]); + cardinality = 1; + } + } else if(typeof index.keyPath === 'object' && index.keyPath.length > cardinality) { + // compound index + var valid = true; + var keyValue = _.map(index.keyPath, function(keyPart) { + valid = valid && json[keyPart] !== undefined; + return json[keyPart]; + }); + if(valid) { + getRequest = index.get(keyValue); + cardinality = index.keyPath.length; + } + } + }); + } + if (getRequest) { + getRequest.onsuccess = function (event) { + if (event.target.result) { + options.success(event.target.result); + } else { + options.error("Not Found"); + } + }; + getRequest.onerror = function () { + options.error("Not Found"); // We couldn't find the record. + } + } else { + options.error("Not Found"); // We couldn't even look for it, as we don't have enough data. + } + }, + + // Deletes the json.id key and value in storeName from db. + delete: function (storeName, object, options) { + var deleteTransaction = this.db.transaction([storeName], 'readwrite'); + //this._track_transaction(deleteTransaction); + + var store = deleteTransaction.objectStore(storeName); + var json = object.toJSON(); + var idAttribute = _.result(object, 'idAttribute'); + + var deleteRequest = store.delete(json[idAttribute]); + + deleteTransaction.oncomplete = function (event) { + options.success(null); + }; + deleteRequest.onerror = function (event) { + options.error("Not Deleted"); + }; + }, + + // Clears all records for storeName from db. + clear: function (storeName, object, options) { + var deleteTransaction = this.db.transaction([storeName], "readwrite"); + //this._track_transaction(deleteTransaction); + + var store = deleteTransaction.objectStore(storeName); + + var deleteRequest = store.clear(); + deleteRequest.onsuccess = function (event) { + options.success(null); + }; + deleteRequest.onerror = function (event) { + options.error("Not Cleared"); + }; + }, + + // Performs a query on storeName in db. + // options may include : + // - conditions : value of an index, or range for an index + // - range : range for the primary key + // - limit : max number of elements to be yielded + // - offset : skipped items. + query: function (storeName, collection, options) { + var elements = []; + var skipped = 0, processed = 0; + var queryTransaction = this.db.transaction([storeName], "readonly"); + //this._track_transaction(queryTransaction); + + var idAttribute = _.result(collection.model.prototype, 'idAttribute'); + var readCursor = null; + var store = queryTransaction.objectStore(storeName); + var index = null, + lower = null, + upper = null, + bounds = null; + + if (options.conditions) { + // We have a condition, we need to use it for the cursor + _.each(store.indexNames, function (key) { + if (!readCursor) { + index = store.index(key); + if (options.conditions[index.keyPath] instanceof Array) { + lower = options.conditions[index.keyPath][0] > options.conditions[index.keyPath][1] ? options.conditions[index.keyPath][1] : options.conditions[index.keyPath][0]; + upper = options.conditions[index.keyPath][0] > options.conditions[index.keyPath][1] ? options.conditions[index.keyPath][0] : options.conditions[index.keyPath][1]; + bounds = IDBKeyRange.bound(lower, upper, true, true); + + if (options.conditions[index.keyPath][0] > options.conditions[index.keyPath][1]) { + // Looks like we want the DESC order + readCursor = index.openCursor(bounds, window.IDBCursor.PREV || "prev"); + } else { + // We want ASC order + readCursor = index.openCursor(bounds, window.IDBCursor.NEXT || "next"); + } + } else if (typeof options.conditions[index.keyPath] === 'object' && ('$gt' in options.conditions[index.keyPath] || '$gte' in options.conditions[index.keyPath])) { + if('$gt' in options.conditions[index.keyPath]) + bounds = IDBKeyRange.lowerBound(options.conditions[index.keyPath]['$gt'], true); + else + bounds = IDBKeyRange.lowerBound(options.conditions[index.keyPath]['$gte']); + readCursor = index.openCursor(bounds, window.IDBCursor.NEXT || "next"); + } else if (typeof options.conditions[index.keyPath] === 'object' && ('$lt' in options.conditions[index.keyPath] || '$lte' in options.conditions[index.keyPath])) { + if('$lt' in options.conditions[index.keyPath]) + bounds = IDBKeyRange.upperBound(options.conditions[index.keyPath]['$lt'], true); + else + bounds = IDBKeyRange.upperBound(options.conditions[index.keyPath]['$lte']); + readCursor = index.openCursor(bounds, window.IDBCursor.NEXT || "next"); + } else if (options.conditions[index.keyPath] != undefined) { + bounds = IDBKeyRange.only(options.conditions[index.keyPath]); + readCursor = index.openCursor(bounds); + } + } + }); + } else { + // No conditions, use the index + if (options.range) { + lower = options.range[0] > options.range[1] ? options.range[1] : options.range[0]; + upper = options.range[0] > options.range[1] ? options.range[0] : options.range[1]; + bounds = IDBKeyRange.bound(lower, upper); + if (options.range[0] > options.range[1]) { + readCursor = store.openCursor(bounds, window.IDBCursor.PREV || "prev"); + } else { + readCursor = store.openCursor(bounds, window.IDBCursor.NEXT || "next"); + } + } else { + readCursor = store.openCursor(); + } + } + + if (typeof (readCursor) == "undefined" || !readCursor) { + options.error("No Cursor"); + } else { + readCursor.onerror = function(e){ + options.error("readCursor error", e); + }; + // Setup a handler for the cursor’s `success` event: + readCursor.onsuccess = function (e) { + var cursor = e.target.result; + if (!cursor) { + if (options.addIndividually || options.clear) { + // nothing! + // We need to indicate that we're done. But, how? + collection.trigger("reset"); + } else { + options.success(elements); // We're done. No more elements. + } + } + else { + // Cursor is not over yet. + if (options.limit && processed >= options.limit) { + // Yet, we have processed enough elements. So, let's just skip. + if (bounds && options.conditions[index.keyPath]) { + cursor.continue(options.conditions[index.keyPath][1] + 1); /* We need to 'terminate' the cursor cleany, by moving to the end */ + } else { + cursor.continue(); /* We need to 'terminate' the cursor cleany, by moving to the end */ + } + } + else if (options.offset && options.offset > skipped) { + skipped++; + cursor.continue(); /* We need to Moving the cursor forward */ + } else { + // This time, it looks like it's good! + if (options.addIndividually) { + collection.add(cursor.value); + } else if (options.clear) { + var deleteRequest = store.delete(cursor.value[idAttribute]); + deleteRequest.onsuccess = function (event) { + elements.push(cursor.value); + }; + deleteRequest.onerror = function (event) { + elements.push(cursor.value); + }; + + } else { + elements.push(cursor.value); + } + processed++; + cursor.continue(); + } + } + }; + } + }, + close :function(){ + if(this.db){ + this.db.close(); + } + } + }; + + // ExecutionQueue object + // The execution queue is an abstraction to buffer up requests to the database. + // It holds a "driver". When the driver is ready, it just fires up the queue and executes in sync. + function ExecutionQueue(schema,next,nolog) { + this.driver = new Driver(schema, this.ready.bind(this), nolog, this.error.bind(this)); + this.started = false; + this.failed = false; + this.stack = []; + this.version = _.last(schema.migrations).version; + this.next = next; + } + + // ExecutionQueue Prototype + ExecutionQueue.prototype = { + // Called when the driver is ready + // It just loops over the elements in the queue and executes them. + ready: function () { + this.started = true; + _.each(this.stack, function (message) { + this.execute(message); + }.bind(this)); + this.stack = []; // fix memory leak + this.next(); + }, + + error: function() { + this.failed = true; + _.each(this.stack, function (message) { + this.execute(message); + }.bind(this)); + this.stack = []; + this.next(); + }, + + // Executes a given command on the driver. If not started, just stacks up one more element. + execute: function (message) { + if (this.started) { + this.driver.execute(message[2].storeName || message[1].storeName, message[0], message[1], message[2]); // Upon messages, we execute the query + } else if (this.failed) { + message[2].error(); + } else { + this.stack.push(message); + } + }, + + close : function(){ + this.driver.close(); + } + }; + + // Method used by Backbone for sync of data with data store. It was initially designed to work with "server side" APIs, This wrapper makes + // it work with the local indexedDB stuff. It uses the schema attribute provided by the object. + // The wrapper keeps an active Executuon Queue for each "schema", and executes querues agains it, based on the object type (collection or + // single model), but also the method... etc. + // Keeps track of the connections + var Databases = {}; + + function sync(method, object, options) { + + if(method == "closeall"){ + _.each(Databases,function(database){ + database.close(); + }); + // Clean up active databases object. + Databases = {}; + return Backbone.$.Deferred().resolve(); + } + + // If a model or a collection does not define a database, fall back on ajaxSync + if (!object || !_.isObject(object.database)) { + return Backbone.ajaxSync(method, object, options); + } + + var schema = object.database; + if (Databases[schema.id]) { + if(Databases[schema.id].version != _.last(schema.migrations).version){ + Databases[schema.id].close(); + delete Databases[schema.id]; + } + } + + var promise; + + if (typeof Backbone.$ === 'undefined' || typeof Backbone.$.Deferred === 'undefined') { + var noop = function() {}; + var resolve = noop; + var reject = noop; + } else { + var dfd = Backbone.$.Deferred(); + var resolve = dfd.resolve; + var reject = dfd.reject; + + promise = dfd.promise(); + } + + var success = options.success; + options.success = function(resp) { + if (success) success(resp); + resolve(); + object.trigger('sync', object, resp, options); + }; + + var error = options.error; + options.error = function(resp) { + if (error) error(resp); + reject(); + object.trigger('error', object, resp, options); + }; + + var next = function(){ + Databases[schema.id].execute([method, object, options]); + }; + + if (!Databases[schema.id]) { + Databases[schema.id] = new ExecutionQueue(schema,next,schema.nolog); + } else { + next(); + } + + return promise; + }; + + Backbone.ajaxSync = Backbone.sync; + Backbone.sync = sync; + + return { sync: sync, debugLog: debugLog}; })); /** diff --git a/js/database.js b/js/database.js new file mode 100644 index 00000000..b45702a1 --- /dev/null +++ b/js/database.js @@ -0,0 +1,37 @@ +/* vim: ts=4:sw=4:expandtab + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +(function () { + 'use strict'; + window.Whisper = window.Whisper || {}; + window.Whisper.Database = window.Whisper.Database || {}; + window.Whisper.Database.id = window.Whisper.Database.id || 'signal'; + + Whisper.Database.migrations = [ + { + version: "1.0", + migrate: function(transaction, next) { + console.log('migratetion 1.0'); + var messages = transaction.db.createObjectStore("messages"); + messages.createIndex("conversation", "conversationId", { unique: false }); + + var conversations = transaction.db.createObjectStore("conversations"); + conversations.createIndex("timestamp", "timestamp", { unique: false }); + next(); + } + } + ]; +}()); diff --git a/js/index.js b/js/index.js index 292ace94..8f825dfb 100644 --- a/js/index.js +++ b/js/index.js @@ -22,10 +22,15 @@ this.contacts = $('#contacts'); this.resize(); - new Whisper.ConversationListView({el: $('#contacts')}); window.addEventListener('resize', this.resize.bind(this)); - window.addEventListener('storage', function () {Whisper.Threads.fetch(); }); - Whisper.Threads.fetch({reset: true}); + + new Whisper.ConversationListView({el: $('#contacts'), collection: Whisper.Conversations}); + Whisper.Conversations.fetch({reset: true}).then(function() { + if (Whisper.Conversations.length) { + Whisper.Conversations.at(0).trigger('render'); + } + }); + }, events: { 'click #new-message': 'new_message', @@ -69,8 +74,5 @@ } else { textsecure.storage.putUnencrypted("unreadCount", 0); extension.navigator.setBadgeText(""); - if (Whisper.Threads.length) { - Whisper.Threads.at(0).trigger('render'); - } } }()); diff --git a/js/models/threads.js b/js/models/conversations.js similarity index 59% rename from js/models/threads.js rename to js/models/conversations.js index 255bb37c..70d3cbd5 100644 --- a/js/models/threads.js +++ b/js/models/conversations.js @@ -1,5 +1,18 @@ -// vim: ts=2:sw=2:expandtab: - +/* vim: ts=4:sw=4:expandtab + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ (function () { 'use strict'; @@ -20,7 +33,9 @@ })); }; - var Thread = Backbone.Model.extend({ + var Conversation = Whisper.Conversation = Backbone.Model.extend({ + database: Whisper.Database, + storeName: 'conversations', defaults: function() { return { name: 'New Conversation', @@ -31,20 +46,26 @@ }; }, + initialize: function() { + this.messageCollection = new Whisper.MessageCollection(); + }, + validate: function(attributes, options) { var required = ['type', 'timestamp', 'image', 'name']; var missing = _.filter(required, function(attr) { return !attributes[attr]; }); - if (missing.length) { return "Thread must have " + missing; } + if (missing.length) { return "Conversation must have " + missing; } }, sendMessage: function(message, attachments) { return encodeAttachments(attachments).then(function(base64_attachments) { var timestamp = Date.now(); - this.messages().add({ type: 'outgoing', - body: message, - threadId: this.id, - attachments: base64_attachments, - timestamp: timestamp }).save(); + this.messages().add({ body: message, + timestamp: timestamp, + conversationId: this.id, + conversationType: this.get('type'), + type: 'outgoing', + attachments: base64_attachments + }).save(); this.save({ timestamp: timestamp, unreadCount: 0, @@ -64,16 +85,17 @@ }, receiveMessage: function(decrypted) { - var thread = this; + var conversation = this; encodeAttachments(decrypted.message.attachments).then(function(base64_attachments) { var timestamp = decrypted.pushMessage.timestamp.toNumber(); var m = this.messages().add({ - person: decrypted.pushMessage.source, - threadId: this.id, body: decrypted.message.body, + timestamp: timestamp, + conversationId: this.id, + conversationType: this.get('type'), attachments: base64_attachments, type: 'incoming', - timestamp: timestamp + sender: decrypted.pushMessage.source }); m.save(); @@ -85,28 +107,24 @@ }.bind(this)); }, + fetch: function() { + return this.messageCollection.fetch({conditions: {conversationId: this.id }}); + }, + messages: function() { - if (!this.messageCollection) { - this.messageCollection = new Whisper.MessageCollection([], {threadId: this.id}); - } return this.messageCollection; }, }); - Whisper.Threads = new (Backbone.Collection.extend({ - localStorage: new Backbone.LocalStorage("Threads"), - model: Thread, + Whisper.ConversationCollection = Backbone.Collection.extend({ + database: Whisper.Database, + storeName: 'conversations', + model: Conversation, comparator: function(m) { return -m.get('timestamp'); }, - findOrCreate: function(attributes) { - var thread = Whisper.Threads.add(attributes, {merge: true}); - thread.save(); - return thread; - }, - createGroup: function(recipients, name) { var attributes = {}; attributes = { @@ -114,13 +132,13 @@ numbers : recipients, type : 'group', }; - var thread = this.findOrCreate(attributes); + var conversation = this.add(attributes, {merge: true}); return textsecure.messaging.createGroup(recipients, name).then(function(groupId) { - thread.save({ + conversation.save({ id : getString(groupId), groupId : getString(groupId) }); - return thread; + return conversation; }); }, @@ -131,10 +149,12 @@ name : recipient, type : 'private', }; - return this.findOrCreate(attributes); + var conversation = this.add(attributes, {merge: true}); + conversation.save(); + return conversation; }, - findOrCreateForIncomingMessage: function(decrypted) { + addIncomingMessage: function(decrypted) { var attributes = {}; if (decrypted.message.group) { attributes = { @@ -150,12 +170,18 @@ type : 'private' }; } - return this.findOrCreate(attributes); + var conversation = this.add(attributes, {merge: true}); + conversation.receiveMessage(decrypted); }, - addIncomingMessage: function(decrypted) { - var thread = Whisper.Threads.findOrCreateForIncomingMessage(decrypted); - return thread.receiveMessage(decrypted); + destroyAll: function () { + return Promise.all(this.models.map(function(m) { + return new Promise(function(resolve, reject) { + m.destroy().then(resolve).fail(reject); + }); + })); } - }))(); + }); + + Whisper.Conversations = new Whisper.ConversationCollection(); })(); diff --git a/js/models/messages.js b/js/models/messages.js index 0eb86ee9..353992b3 100644 --- a/js/models/messages.js +++ b/js/models/messages.js @@ -1,25 +1,51 @@ -var Whisper = Whisper || {}; - +/* vim: ts=4:sw=4:expandtab + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ (function () { - 'use strict'; + 'use strict'; - var Message = Backbone.Model.extend({ - validate: function(attributes, options) { - var required = ['timestamp', 'threadId']; - var missing = _.filter(required, function(attr) { return !attributes[attr]; }); - if (missing.length) { console.log("Message missing attributes: " + missing); } - }, + window.Whisper = window.Whisper || {}; - thread: function() { - return Whisper.Threads.get(this.get('threadId')); - } - }); + var Message = Backbone.Model.extend({ + database: Whisper.Database, + storeName: 'messages', + defaults: function() { return { timestamp: new Date().getTime() }; }, + validate: function(attributes, options) { + var required = ['timestamp', 'conversationId']; + var missing = _.filter(required, function(attr) { return !attributes[attr]; }); + if (missing.length) { + console.log("Message missing attributes: " + missing); + } + }, - Whisper.MessageCollection = Backbone.Collection.extend({ - model: Message, - comparator: 'timestamp', - initialize: function(models, options) { - this.localStorage = new Backbone.LocalStorage("Messages-" + options.threadId); - } - }); + conversation: function() { + return Whisper.Conversations.get(this.get('conversationId')); + } + }); + + Whisper.MessageCollection = Backbone.Collection.extend({ + model: Message, + database: Whisper.Database, + storeName: 'messages', + comparator: function(m) { return -m.get('timestamp'); }, + destroyAll: function () { + return Promise.all(this.models.map(function(m) { + return new Promise(function(resolve, reject) { + m.destroy().then(resolve).fail(reject); + }); + })); + } + }); })() diff --git a/js/views/conversation_list_view.js b/js/views/conversation_list_view.js index dcc912ae..3a398f8e 100644 --- a/js/views/conversation_list_view.js +++ b/js/views/conversation_list_view.js @@ -7,7 +7,7 @@ var Whisper = Whisper || {}; tagName: 'div', id: 'contacts', itemView: Whisper.ConversationListItemView, - collection: Whisper.Threads, + collection: Whisper.Conversations, events: { 'click .contact': 'select', diff --git a/js/views/conversation_view.js b/js/views/conversation_view.js index 380fd31c..8cb3fde8 100644 --- a/js/views/conversation_view.js +++ b/js/views/conversation_view.js @@ -45,11 +45,11 @@ var Whisper = Whisper || {}; e.preventDefault(); var input = this.$el.find('.send input'); var message = input.val(); - var thread = this.model; + var convo = this.model; if (message.length > 0 || this.fileInput.hasFiles()) { this.fileInput.getFiles().then(function(attachments) { - thread.sendMessage(message, attachments); + convo.sendMessage(message, attachments); }); input.val(""); } diff --git a/js/views/message_list_view.js b/js/views/message_list_view.js index 0c161d4a..8e754ee3 100644 --- a/js/views/message_list_view.js +++ b/js/views/message_list_view.js @@ -13,5 +13,12 @@ var Whisper = Whisper || {}; scrollToBottom: function() { this.$el.scrollTop(this.el.scrollHeight); }, + addAll: function() { + this.$el.html(''); + this.collection.each(function(model) { + var view = new this.itemView({model: model}); + this.$el.prepend(view.render().el); + }); + }, }); })(); diff --git a/js/views/message_view.js b/js/views/message_view.js index 58fa4f07..5777f861 100644 --- a/js/views/message_view.js +++ b/js/views/message_view.js @@ -25,7 +25,7 @@ var Whisper = Whisper || {}; timestamp: moment(this.model.get('timestamp')).fromNow(), attachments: this.model.get('attachments'), bubble_class: this.model.get('type') === 'outgoing' ? 'sent' : 'incoming', - sender: this.model.thread().get('type') === 'group' ? this.model.get('person') : '' + sender: this.model.get('conversationType') === 'group' ? this.model.get('sender') : '' }) ); diff --git a/js/views/new_conversation_view.js b/js/views/new_conversation_view.js index 86b9fcf7..d8ea2b43 100644 --- a/js/views/new_conversation_view.js +++ b/js/views/new_conversation_view.js @@ -67,17 +67,17 @@ var Whisper = Whisper || {}; e.preventDefault(); var number = this.input.verifyNumber(); if (number) { - var thread = Whisper.Threads.findOrCreateForRecipient(number); + var convo = Whisper.Conversations.findOrCreateForRecipient(number); var message_input = this.$el.find('input.send-message'); var message = message_input.val(); if (message.length > 0 || this.fileInput.hasFiles()) { this.fileInput.getFiles().then(function(attachments) { - thread.sendMessage(message, attachments); + convo.sendMessage(message, attachments); }); message_input.val(""); } this.remove(); - thread.trigger('render'); + convo.trigger('render'); } }, diff --git a/js/views/new_group_view.js b/js/views/new_group_view.js index 662dcfab..50acd4e4 100644 --- a/js/views/new_group_view.js +++ b/js/views/new_group_view.js @@ -37,10 +37,10 @@ var Whisper = Whisper || {}; var numbers = this.$el.find('input.numbers').val().split(','); var name = this.$el.find('input.name').val(); var view = this; - Whisper.Threads.createGroup(numbers, name).then(function(thread){ - thread.sendMessage(view.$el.find('input.send-message').val()); + Whisper.Conversations.createGroup(numbers, name).then(function(convo){ + convo.sendMessage(view.$el.find('input.send-message').val()); view.remove(); - thread.trigger('render'); + convo.trigger('render'); }); }, diff --git a/manifest.json b/manifest.json index 2ac6be0a..3a612381 100644 --- a/manifest.json +++ b/manifest.json @@ -6,6 +6,10 @@ "version": "0.0.1", "offline_enabled": false, + "permissions": [ + "unlimitedStorage" + ], + "icons": { "128": "icon.png" }, "browser_action": { diff --git a/options.html b/options.html index 74979916..e25dba54 100644 --- a/options.html +++ b/options.html @@ -95,6 +95,7 @@ + @@ -108,7 +109,7 @@ - + diff --git a/test/_test.js b/test/_test.js index 4ff896cd..6c8233ae 100644 --- a/test/_test.js +++ b/test/_test.js @@ -53,6 +53,11 @@ window.assert = chai.assert; mocha.reporter(SauceReporter); }()); +// Override the database id. +window.Whisper = window.Whisper || {}; +window.Whisper.Database = window.Whisper.Database || {}; +Whisper.Database.id = 'test'; + /* * global helpers for tests */ diff --git a/test/index.html b/test/index.html index bcfb669f..9d07a348 100644 --- a/test/index.html +++ b/test/index.html @@ -134,8 +134,9 @@ + - + @@ -161,7 +162,8 @@ - + + diff --git a/test/models/conversations_test.js b/test/models/conversations_test.js new file mode 100644 index 00000000..c2e55e60 --- /dev/null +++ b/test/models/conversations_test.js @@ -0,0 +1,149 @@ +/* vim: ts=4:sw=4:expandtab + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +(function () { + 'use strict'; + function clear(done) { + var convos = new Whisper.ConversationCollection(); + return convos.fetch().then(function() { + convos.destroyAll().then(function() { + var messages = new Whisper.MessageCollection(); + return messages.fetch().then(function() { + messages.destroyAll().then(function() { + done(); + }); + }); + }); + }); + } + + var attributes = { type: 'outgoing', + body: 'hi', + conversationId: 'foo', + attachments: [], + timestamp: new Date().getTime() }; + + describe('ConversationCollection', function() { + before(clear); + after(clear); + + it('adds without saving', function() { + var convos = new Whisper.ConversationCollection(); + var message = convos.add(attributes); + assert.notEqual(convos.length, 0); + + var convos = new Whisper.ConversationCollection(); + assert.strictEqual(convos.length, 0); + }); + + it('saves asynchronously', function(done) { + new Whisper.ConversationCollection().add(attributes).save().then(done); + }); + + it('fetches persistent convos', function(done) { + var convos = new Whisper.ConversationCollection(); + assert.strictEqual(convos.length, 0); + convos.fetch().then(function() { + var m = convos.at(0).attributes; + _.each(attributes, function(val, key) { + assert.deepEqual(m[key], val); + }); + done(); + }); + }); + + it('destroys persistent convos', function(done) { + var convos = new Whisper.ConversationCollection(); + convos.fetch().then(function() { + convos.destroyAll().then(function() { + var convos = new Whisper.ConversationCollection(); + convos.fetch().then(function() { + assert.strictEqual(convos.length, 0); + done(); + }); + }); + }); + }); + + it('should be ordered newest to oldest', function() { + var conversations = new Whisper.ConversationCollection(); + // Timestamps + var today = new Date(); + var tomorrow = new Date(); + tomorrow.setDate(today.getDate()+1); + + // Add convos + conversations.add({ timestamp: today }); + conversations.add({ timestamp: tomorrow }); + + var models = conversations.models; + var firstTimestamp = models[0].get('timestamp').getTime(); + var secondTimestamp = models[1].get('timestamp').getTime(); + + // Compare timestamps + assert(firstTimestamp > secondTimestamp); + }); + }); + + describe('Conversation', function() { + var attributes = { type: 'private', id: 'foobar' }; + before(function(done) { + var convo = new Whisper.ConversationCollection().add(attributes); + convo.save().then(function() { + var message = convo.messages().add({body: 'hello world', conversationId: convo.id}); + message.save().then(done) + }); + }); + after(clear); + + it('contains its own messages', function (done) { + var convo = new Whisper.ConversationCollection().add({id: 'foobar'}); + convo.fetch().then(function() { + assert.notEqual(convo.messages().length, 0); + done(); + }); + }); + + it('contains only its own messages', function (done) { + var convo = new Whisper.ConversationCollection().add({id: 'barfoo'}); + convo.fetch().then(function() { + assert.strictEqual(convo.messages().length, 0); + done(); + }); + }); + + it('has most recent messages first', function(done) { + var convo = new Whisper.ConversationCollection().add({id: 'barfoo'}); + convo.messages().add({ + body: 'first message', + conversationId: convo.id, + timestamp: new Date().getTime() - 5000 + }).save().then(function() { + convo.messages().add({ + body: 'second message', + conversationId: convo.id + }).save().then(function() { + convo.fetch().then(function() { + assert.strictEqual(convo.messages().at(0).get('body'), 'second message'); + assert.strictEqual(convo.messages().at(1).get('body'), 'first message'); + done(); + }); + }); + }); + }); + }); + +})();; diff --git a/test/models/messages_test.js b/test/models/messages_test.js new file mode 100644 index 00000000..af8c9d60 --- /dev/null +++ b/test/models/messages_test.js @@ -0,0 +1,76 @@ +/* vim: ts=4:sw=4:expandtab + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ +(function () { + 'use strict'; + + function clear(done) { + var messages = new Whisper.MessageCollection(); + return messages.fetch().then(function() { + messages.destroyAll(); + done(); + }); + } + + var attributes = { type: 'outgoing', + body: 'hi', + conversationId: 'foo', + attachments: [], + timestamp: new Date().getTime() }; + + describe('MessageCollection', function() { + before(clear); + after(clear); + + it('adds without saving', function() { + var messages = new Whisper.MessageCollection(); + var message = messages.add(attributes); + assert.notEqual(messages.length, 0); + + var messages = new Whisper.MessageCollection(); + assert.strictEqual(messages.length, 0); + }); + + it('saves asynchronously', function(done) { + new Whisper.MessageCollection().add(attributes).save().then(done); + }); + + it('fetches persistent messages', function(done) { + var messages = new Whisper.MessageCollection(); + assert.strictEqual(messages.length, 0); + messages.fetch().then(function() { + assert.notEqual(messages.length, 0); + var m = messages.at(0).attributes; + _.each(attributes, function(val, key) { + assert.deepEqual(m[key], val); + }); + done(); + }); + }); + + it('destroys persistent messages', function(done) { + var messages = new Whisper.MessageCollection(); + messages.fetch().then(function() { + messages.destroyAll().then(function() { + var messages = new Whisper.MessageCollection(); + messages.fetch().then(function() { + assert.strictEqual(messages.length, 0); + done(); + }); + }); + }); + }); + }); +})(); diff --git a/test/test.js b/test/test.js index 9633d166..12ff306f 100644 --- a/test/test.js +++ b/test/test.js @@ -10927,6 +10927,11 @@ window.assert = chai.assert; mocha.reporter(SauceReporter); }()); +// Override the database id. +window.Whisper = window.Whisper || {}; +window.Whisper.Database = window.Whisper.Database || {}; +Whisper.Database.id = 'test'; + /* * global helpers for tests */ diff --git a/test/views/message_view_test.js b/test/views/message_view_test.js index ec20da14..9a1ad3d2 100644 --- a/test/views/message_view_test.js +++ b/test/views/message_view_test.js @@ -1,7 +1,11 @@ describe('MessageView', function() { - var thread = Whisper.Threads.add({id: 'foo'}); - var message = thread.messages().add({ - threadId: thread.id, + before(function(done) { + Whisper.Conversations.fetch().then(done); + }); + + var convo = Whisper.Conversations.add({id: 'foo'}); + var message = convo.messages().add({ + conversationId: convo.id, body: 'hello world', type: 'outgoing', timestamp: new Date().getTime()