From 5d9c1c341312f938e8a0cf7e77625729348c712f Mon Sep 17 00:00:00 2001 From: Mo Bitar Date: Sat, 28 Jan 2017 13:58:02 -0600 Subject: [PATCH] notes infinite scroll --- .../app/frontend/controllers/notes.js | 5 ++ .../javascripts/app/services/authManager.js | 76 ---------------- .../directives/functional/infiniteScroll.js | 19 ++++ .../services/directives/views/accountMenu.js | 88 +++++++++++++++++-- .../app/services/filters/startFrom.js | 6 ++ .../javascripts/app/services/modelManager.js | 2 +- .../javascripts/app/services/syncManager.js | 27 +++++- app/assets/stylesheets/app/_header.scss | 82 ++++++----------- .../directives/account-menu.html.haml | 8 +- .../global-extensions-menu.html.haml | 6 +- app/assets/templates/frontend/notes.html.haml | 15 ++-- 11 files changed, 180 insertions(+), 154 deletions(-) create mode 100644 app/assets/javascripts/app/services/directives/functional/infiniteScroll.js create mode 100644 app/assets/javascripts/app/services/filters/startFrom.js diff --git a/app/assets/javascripts/app/frontend/controllers/notes.js b/app/assets/javascripts/app/frontend/controllers/notes.js index a9e4c37f2..5bb7fba5a 100644 --- a/app/assets/javascripts/app/frontend/controllers/notes.js +++ b/app/assets/javascripts/app/frontend/controllers/notes.js @@ -32,6 +32,11 @@ angular.module('app.frontend') var isFirstLoad = true; + this.notesToDisplay = 20; + this.paginate = function() { + this.notesToDisplay += 20 + } + this.tagDidChange = function(tag, oldTag) { this.showMenu = false; diff --git a/app/assets/javascripts/app/services/authManager.js b/app/assets/javascripts/app/services/authManager.js index 72a1dbe84..f874b1a7d 100644 --- a/app/assets/javascripts/app/services/authManager.js +++ b/app/assets/javascripts/app/services/authManager.js @@ -167,82 +167,6 @@ angular.module('app.frontend') }) } - /* - Import - */ - - this.importJSONData = function(data, password, callback) { - console.log("Importing data", data); - - var onDataReady = function() { - var items = modelManager.mapResponseItemsToLocalModels(data.items); - items.forEach(function(item){ - item.setDirty(true); - item.markAllReferencesDirty(); - }) - this.syncWithOptions(callback, {additionalFields: ["created_at", "updated_at"]}); - }.bind(this) - - if(data.auth_params) { - Neeto.crypto.computeEncryptionKeysForUser(_.merge({password: password}, data.auth_params), function(keys){ - var mk = keys.mk; - try { - this.decryptItemsWithKey(data.items, mk); - // delete items enc_item_key since the user's actually key will do the encrypting once its passed off - data.items.forEach(function(item){ - item.enc_item_key = null; - item.auth_hash = null; - }) - onDataReady(); - } - catch (e) { - console.log("Error decrypting", e); - alert("There was an error decrypting your items. Make sure the password you entered is correct and try again."); - callback(false, null); - return; - } - }.bind(this)); - } else { - onDataReady(); - } - } - - /* - Export - */ - - this.itemsDataFile = function(ek) { - var textFile = null; - var makeTextFile = function (text) { - var data = new Blob([text], {type: 'text/json'}); - - // If we are replacing a previously generated file we need to - // manually revoke the object URL to avoid memory leaks. - if (textFile !== null) { - window.URL.revokeObjectURL(textFile); - } - - textFile = window.URL.createObjectURL(data); - - // returns a URL you can use as a href - return textFile; - }.bind(this); - - var items = _.map(modelManager.allItemsMatchingTypes(["Tag", "Note"]), function(item){ - var itemParams = new ItemParams(item, ek); - return itemParams.paramsForExportFile(); - }.bind(this)); - - var data = { - items: items - } - - // auth params are only needed when encrypted with a standard file key - data["auth_params"] = this.getAuthParams(); - - return makeTextFile(JSON.stringify(data, null, 2 /* pretty print */)); - } - this.staticifyObject = function(object) { return JSON.parse(JSON.stringify(object)); } diff --git a/app/assets/javascripts/app/services/directives/functional/infiniteScroll.js b/app/assets/javascripts/app/services/directives/functional/infiniteScroll.js new file mode 100644 index 000000000..a9f565db3 --- /dev/null +++ b/app/assets/javascripts/app/services/directives/functional/infiniteScroll.js @@ -0,0 +1,19 @@ +angular.module('app.frontend').directive('infiniteScroll', [ +'$rootScope', '$window', '$timeout', function($rootScope, $window, $timeout) { + return { + link: function(scope, elem, attrs) { + elem.css('overflow-x', 'hidden'); + elem.css('height', 'inherit'); + + var offset = parseInt(attrs.threshold) || 0; + var e = elem[0] + + elem.on('scroll', function(){ + if(scope.$eval(attrs.canLoad) && e.scrollTop + e.offsetHeight >= e.scrollHeight - offset) { + scope.$apply(attrs.infiniteScroll); + } + }); + } + }; +} +]); diff --git a/app/assets/javascripts/app/services/directives/views/accountMenu.js b/app/assets/javascripts/app/services/directives/views/accountMenu.js index a29a90db8..dfc1a6457 100644 --- a/app/assets/javascripts/app/services/directives/views/accountMenu.js +++ b/app/assets/javascripts/app/services/directives/views/accountMenu.js @@ -85,7 +85,6 @@ class AccountMenu { }) } - /* Import/Export */ $scope.archiveFormData = {encrypted: $scope.user ? true : false}; @@ -97,16 +96,20 @@ class AccountMenu { var ek = $scope.archiveFormData.encrypted ? syncManager.masterKey : null; - link.href = authManager.itemsDataFile(ek); + link.href = $scope.itemsDataFile(ek); link.click(); } + $scope.submitImportPassword = function() { + $scope.performImport($scope.importData.data, $scope.importData.password); + } + $scope.performImport = function(data, password) { $scope.importData.loading = true; // allow loading indicator to come up with timeout $timeout(function(){ - authManager.importJSONData(data, password, function(success, response){ - console.log("Import response:", success, response); + $scope.importJSONData(data, password, function(success, response){ + // console.log("Import response:", success, response); $scope.importData.loading = false; if(success) { $scope.importData = null; @@ -117,10 +120,6 @@ class AccountMenu { }) } - $scope.submitImportPassword = function() { - $scope.performImport($scope.importData.data, $scope.importData.password); - } - $scope.importFileSelected = function(files) { $scope.importData = {}; @@ -147,6 +146,79 @@ class AccountMenu { return allNotes.length + "/" + allNotes.length + " notes encrypted"; } + $scope.importJSONData = function(data, password, callback) { + console.log("Importing data", data); + + var onDataReady = function() { + var items = modelManager.mapResponseItemsToLocalModels(data.items); + items.forEach(function(item){ + item.setDirty(true); + item.markAllReferencesDirty(); + }) + + syncManager.sync(callback, {additionalFields: ["created_at", "updated_at"]}); + }.bind(this) + + if(data.auth_params) { + Neeto.crypto.computeEncryptionKeysForUser(_.merge({password: password}, data.auth_params), function(keys){ + var mk = keys.mk; + try { + EncryptionHelper.decryptMultipleItems(data.items, mk); + // delete items enc_item_key since the user's actually key will do the encrypting once its passed off + data.items.forEach(function(item){ + item.enc_item_key = null; + item.auth_hash = null; + }) + onDataReady(); + } + catch (e) { + console.log("Error decrypting", e); + alert("There was an error decrypting your items. Make sure the password you entered is correct and try again."); + callback(false, null); + return; + } + }.bind(this)); + } else { + onDataReady(); + } + } + + /* + Export + */ + + $scope.itemsDataFile = function(ek) { + var textFile = null; + var makeTextFile = function (text) { + var data = new Blob([text], {type: 'text/json'}); + + // If we are replacing a previously generated file we need to + // manually revoke the object URL to avoid memory leaks. + if (textFile !== null) { + window.URL.revokeObjectURL(textFile); + } + + textFile = window.URL.createObjectURL(data); + + // returns a URL you can use as a href + return textFile; + }.bind(this); + + var items = _.map(modelManager.allItemsMatchingTypes(["Tag", "Note"]), function(item){ + var itemParams = new ItemParams(item, ek); + return itemParams.paramsForExportFile(); + }.bind(this)); + + var data = { + items: items + } + + // auth params are only needed when encrypted with a standard file key + data["auth_params"] = authManager.getAuthParams(); + + return makeTextFile(JSON.stringify(data, null, 2 /* pretty print */)); + } + } } diff --git a/app/assets/javascripts/app/services/filters/startFrom.js b/app/assets/javascripts/app/services/filters/startFrom.js new file mode 100644 index 000000000..2ebd72e42 --- /dev/null +++ b/app/assets/javascripts/app/services/filters/startFrom.js @@ -0,0 +1,6 @@ +// Start from filter +angular.module('app.frontend').filter('startFrom', function() { + return function(input, start) { + return input.slice(start); + }; +}); diff --git a/app/assets/javascripts/app/services/modelManager.js b/app/assets/javascripts/app/services/modelManager.js index 9b8d62e70..b919c2712 100644 --- a/app/assets/javascripts/app/services/modelManager.js +++ b/app/assets/javascripts/app/services/modelManager.js @@ -76,7 +76,7 @@ class ModelManager { this.notifySyncObserversOfModels(models); - this.sortItems(); + // this.sortItems(); return models; } diff --git a/app/assets/javascripts/app/services/syncManager.js b/app/assets/javascripts/app/services/syncManager.js index e2c8aa1ef..07cff13ec 100644 --- a/app/assets/javascripts/app/services/syncManager.js +++ b/app/assets/javascripts/app/services/syncManager.js @@ -65,6 +65,19 @@ class SyncManager { return this.serverURL + "/items/sync"; } + set syncToken(token) { + console.log("setting token", token); + this._syncToken = token; + localStorage.setItem("syncToken", token); + } + + get syncToken() { + if(!this._syncToken) { + this._syncToken = localStorage.getItem("syncToken"); + } + return this._syncToken; + } + sync(callback, options = {}) { if(this.syncStatus.syncOpInProgress) { @@ -75,6 +88,8 @@ class SyncManager { var allDirtyItems = this.modelManager.getDirtyItems(); + console.log("Syncing dirty items", allDirtyItems); + // we want to write all dirty items to disk only if the user is offline, or if the sync op fails // if the sync op succeeds, these items will be written to disk by handling the "saved_items" response from the server if(this.authManager.offline()) { @@ -113,10 +128,13 @@ class SyncManager { request.sync_token = this.syncToken; request.cursor_token = this.cursorToken; + console.log("Syncing with token", request.sync_token, request.cursor_token); + request.post().then(function(response) { + console.log("Sync completion", response.plain()); + this.modelManager.clearDirtyItems(subItems); this.syncStatus.error = null; - this.syncToken = response.sync_token; this.cursorToken = response.cursor_token; this.$rootScope.$broadcast("sync:updated_token", this.syncToken); @@ -134,8 +152,13 @@ class SyncManager { this.syncStatus.syncOpInProgress = false; this.syncStatus.current += subItems.length; + // set the sync token at the end, so that if any errors happen above, you can resync + this.syncToken = response.sync_token; + if(this.cursorToken || this.repeatOnCompletion || this.needsMoreSync) { - this.sync(callback, options); + setTimeout(function () { + this.sync(callback, options); + }.bind(this), 10); // wait 10ms to allow UI to update } else { if(callback) { callback(response); diff --git a/app/assets/stylesheets/app/_header.scss b/app/assets/stylesheets/app/_header.scss index be5c8c8ce..cee146988 100644 --- a/app/assets/stylesheets/app/_header.scss +++ b/app/assets/stylesheets/app/_header.scss @@ -30,6 +30,10 @@ margin-bottom: 10px !important; } +.mr-5 { + margin-right: 5px; +} + .faded { opacity: 0.5; } @@ -230,44 +234,26 @@ cursor: default; overflow: auto; background-color: white; +} - button.light { - font-weight: bold; - margin-bottom: 0px; - font-size: 12px; - height: 30px; - padding-top: 3px; - text-align: center; - margin-bottom: 6px; - background-color: white; - display: block; - width: 100%; - border: 1px solid rgba(gray, 0.15); - cursor: pointer; - color: $blue-color; +button.light { + font-weight: bold; + margin-bottom: 0px; + font-size: 12px; + height: 30px; + padding-top: 3px; + text-align: center; + margin-bottom: 6px; + background-color: white; + display: block; + width: 100%; + border: 1px solid rgba(gray, 0.15); + cursor: pointer; + color: $blue-color; - &:hover { - background-color: rgba(gray, 0.10); - } - - .execution-spinner { - margin-left: auto; - margin-right: auto; - text-align: center; - margin-top: 3px; - } - } - - .storage-text { - font-size: 14px; - } - - .checkbox { - font-size: 14px; - font-weight: normal; - margin-left: auto; - margin-right: auto; - } + &:hover { + background-color: rgba(gray, 0.10); + } } .half-button { @@ -293,25 +279,6 @@ cursor: default !important; } -.import-password { - margin-top: 14px; - - > .field { - display: block; - margin: 5px 0px; - } -} - -.encryption-confirmation { - position: relative; - .buttons { - .cancel { - font-weight: normal; - margin-right: 3px; - } - } -} - a.disabled { pointer-events: none; } @@ -323,6 +290,11 @@ a.disabled { border: 1px solid #515263; border-right-color: transparent; border-radius: 50%; + + &.blue { + border: 1px solid $blue-color; + border-right-color: transparent; + } } @keyframes rotate { diff --git a/app/assets/templates/frontend/directives/account-menu.html.haml b/app/assets/templates/frontend/directives/account-menu.html.haml index 18df4f476..726ab702d 100644 --- a/app/assets/templates/frontend/directives/account-menu.html.haml +++ b/app/assets/templates/frontend/directives/account-menu.html.haml @@ -29,7 +29,9 @@ %h2 {{user.email}} %p {{server}} - %p.bold.mt-10.blue{"delay-hide" => "true", "show" => "syncStatus.syncOpInProgress", "delay" => "1000"} Syncing: {{syncStatus.current}}/{{syncStatus.total}} + %div.bold.mt-10.blue{"delay-hide" => "true", "show" => "syncStatus.syncOpInProgress", "delay" => "1000"} + .spinner.inline.mr-5.blue + Syncing: {{syncStatus.current}}/{{syncStatus.total}} %p.bold.mt-10.red.block{"ng-if" => "syncStatus.error"} Error syncing: {{syncStatus.error.message}} .medium-v-space @@ -53,7 +55,7 @@ %a.block{"ng-click" => "downloadDataArchive()"} Download Data Archive %label.block.mt-5 - %input{"type" => "file", "style" => "display: none;", "file-change" => "->", "handler" => "ctrl.importFileSelected(files)"} + %input{"type" => "file", "style" => "display: none;", "file-change" => "->", "handler" => "importFileSelected(files)"} .fake-link Import Data from Archive %div{"ng-if" => "importData.requestPassword"} @@ -61,6 +63,6 @@ %input{"type" => "text", "ng-model" => "importData.password"} %button{"ng-click" => "submitImportPassword()"} Decrypt & Import - .spinner{"ng-if" => "importData.loading"} + .spinner.mt-10{"ng-if" => "importData.loading"} %a.block.mt-25.red{"ng-click" => "destroyLocalData()"} Destroy all local data diff --git a/app/assets/templates/frontend/directives/global-extensions-menu.html.haml b/app/assets/templates/frontend/directives/global-extensions-menu.html.haml index 9f9f8d57a..2c33eac60 100644 --- a/app/assets/templates/frontend/directives/global-extensions-menu.html.haml +++ b/app/assets/templates/frontend/directives/global-extensions-menu.html.haml @@ -34,13 +34,15 @@ %button.light{"ng-if" => "!extensionManager.isRepeatActionEnabled(action)", "ng-click" => "extensionManager.enableRepeatAction(action, extension)"} Enable %button.light.mt-10{"ng-if" => "!action.running && !action.repeat_mode", "ng-click" => "selectedAction(action, extension)"} Perform Action - .spinner.execution-spinner.mb-5.centered.center-align.block{"ng-if" => "action.running"} + .spinner.mb-5.centered.center-align.block{"ng-if" => "action.running"} %p.mb-5.mt-5.small{"ng-if" => "!action.error && action.lastExecuted && !action.running"} Last run {{action.lastExecuted | appDateTime}} %label.red{"ng-if" => "action.error"} Error performing action. - %a.block.center-align.mt-10{"ng-click" => "deleteExtension(extension)"} Remove extension + %a.block.center-align.mt-10{"ng-click" => "extension.showURL = !extension.showURL"} Show URL + %p.center-align.wrap{"ng-if" => "extension.showURL"} {{extension.url}} + %a.block.center-align.mt-5{"ng-click" => "deleteExtension(extension)"} Remove extension .large-v-space diff --git a/app/assets/templates/frontend/notes.html.haml b/app/assets/templates/frontend/notes.html.haml index 0d5b71103..a185c4b90 100644 --- a/app/assets/templates/frontend/notes.html.haml +++ b/app/assets/templates/frontend/notes.html.haml @@ -18,10 +18,11 @@ %li %a.text{"ng-click" => "ctrl.selectedMenuItem(); ctrl.selectedTagDelete()"} Delete Tag - .note{"ng-repeat" => "note in ctrl.tag.notes | filter: ctrl.filterNotes", - "ng-click" => "ctrl.selectNote(note)", "ng-class" => "{'selected' : ctrl.selectedNote == note}"} - .name{"ng-if" => "note.title"} - {{note.title}} - .note-preview - {{note.text}} - .date {{(note.created_at | appDateTime) || 'Now'}} + %div{"infinite-scroll" => "ctrl.paginate()", "can-load" => "true", "threshold" => "200"} + .note{"ng-repeat" => "note in ctrl.tag.notes | limitTo:ctrl.notesToDisplay | filter: ctrl.filterNotes", + "ng-click" => "ctrl.selectNote(note)"} + .name{"ng-if" => "note.title"} + {{note.title}} + .note-preview + {{note.text}} + .date {{(note.created_at | appDateTime) || 'Now'}}