diff --git a/app/assets/javascripts/app/frontend/controllers/editor.js b/app/assets/javascripts/app/frontend/controllers/editor.js index a12a75394..a4eeafd21 100644 --- a/app/assets/javascripts/app/frontend/controllers/editor.js +++ b/app/assets/javascripts/app/frontend/controllers/editor.js @@ -20,7 +20,7 @@ angular.module('app.frontend') * Insert 4 spaces when a tab key is pressed, * only used when inside of the text editor. * If the shift key is pressed first, this event is - * not fired. + * not fired. */ var handleTab = function (event) { if (!event.shiftKey && event.which == 9) { @@ -146,8 +146,6 @@ angular.module('app.frontend') note.dummy = false; this.save()(note, function(success){ if(success) { - apiController.clearDraft(); - if(statusTimeout) $timeout.cancel(statusTimeout); statusTimeout = $timeout(function(){ this.noteStatus = "All changes saved" @@ -171,10 +169,6 @@ angular.module('app.frontend') this.changesMade = function() { this.note.hasChanges = true; this.note.dummy = false; - if(apiController.isUserSignedIn()) { - // signed out users have local autosave, dont need draft saving - apiController.saveDraftToDisk(this.note); - } if(saveTimeout) $timeout.cancel(saveTimeout); if(statusTimeout) $timeout.cancel(statusTimeout); @@ -236,7 +230,6 @@ angular.module('app.frontend') } this.deleteNote = function() { - apiController.clearDraft(); this.remove()(this.note); this.showMenu = false; } diff --git a/app/assets/javascripts/app/frontend/controllers/header.js b/app/assets/javascripts/app/frontend/controllers/header.js index b765b2a63..99b901b80 100644 --- a/app/assets/javascripts/app/frontend/controllers/header.js +++ b/app/assets/javascripts/app/frontend/controllers/header.js @@ -21,7 +21,7 @@ angular.module('app.frontend') this.user = apiController.user; this.accountMenuPressed = function() { - this.serverData = {url: apiController.getServer()}; + this.serverData = {url: syncManager.serverURL()}; this.showAccountMenu = !this.showAccountMenu; this.showFaq = false; this.showNewPasswordForm = false; diff --git a/app/assets/javascripts/app/frontend/controllers/home.js b/app/assets/javascripts/app/frontend/controllers/home.js index 9d03da457..7f46bd22c 100644 --- a/app/assets/javascripts/app/frontend/controllers/home.js +++ b/app/assets/javascripts/app/frontend/controllers/home.js @@ -7,9 +7,9 @@ angular.module('app.frontend') syncManager.sync(null); // refresh every 30s - setInterval(function () { - syncManager.sync(null); - }, 30000); + // setInterval(function () { + // syncManager.sync(null); + // }, 30000); }); $scope.allTag = new Tag({all: true}); diff --git a/app/assets/javascripts/app/frontend/controllers/notes.js b/app/assets/javascripts/app/frontend/controllers/notes.js index 111eb95d8..0a9944c2e 100644 --- a/app/assets/javascripts/app/frontend/controllers/notes.js +++ b/app/assets/javascripts/app/frontend/controllers/notes.js @@ -48,14 +48,8 @@ angular.module('app.frontend') if(isFirstLoad) { $timeout(function(){ - var draft = apiController.getDraft(); - if(draft) { - var note = draft; - this.selectNote(note); - } else { - this.createNewNote(); - isFirstLoad = false; - } + this.createNewNote(); + isFirstLoad = false; }.bind(this)) } else if(tag.notes.length == 0) { this.createNewNote(); diff --git a/app/assets/javascripts/app/frontend/models/local/itemParams.js b/app/assets/javascripts/app/frontend/models/local/itemParams.js index 614f670a6..033360956 100644 --- a/app/assets/javascripts/app/frontend/models/local/itemParams.js +++ b/app/assets/javascripts/app/frontend/models/local/itemParams.js @@ -7,7 +7,7 @@ class ItemParams { } paramsForExportFile() { - this.additionalFields = ["created_at", "updated_at"]; + this.additionalFields = ["updated_at"]; this.forExportFile = true; return _.omit(this.__params(), ["deleted"]); } @@ -16,16 +16,22 @@ class ItemParams { return this.paramsForExportFile(); } + paramsForLocalStorage() { + this.additionalFields = ["updated_at", "dirty"]; + this.forExportFile = true; + return this.__params(); + } + paramsForSync() { - return __params(null, false); + return this.__params(null, false); } __params() { var itemCopy = _.cloneDeep(this.item); - console.assert(!item.dummy, "Item is dummy, should not have gotten here.", item.dummy) + console.assert(!this.item.dummy, "Item is dummy, should not have gotten here.", this.item.dummy) - var params = {uuid: item.uuid, content_type: item.content_type, deleted: item.deleted}; + var params = {uuid: this.item.uuid, content_type: this.item.content_type, deleted: this.item.deleted, created_at: this.item.created_at}; if(this.ek) { this.encryptionHelper.encryptItem(itemCopy, this.ek); @@ -42,7 +48,7 @@ class ItemParams { } if(this.additionalFields) { - _.merge(params, _.pick(item, this.additionalFields)); + _.merge(params, _.pick(this.item, this.additionalFields)); } return params; diff --git a/app/assets/javascripts/app/services/apiController.js b/app/assets/javascripts/app/services/apiController.js index c119cf1ec..bfd1b4007 100644 --- a/app/assets/javascripts/app/services/apiController.js +++ b/app/assets/javascripts/app/services/apiController.js @@ -28,18 +28,10 @@ angular.module('app.frontend') Auth */ - this.defaultServerURL = function() { - return localStorage.getItem("server") || "https://n3.standardnotes.org"; - } - this.getAuthParams = function() { return JSON.parse(localStorage.getItem("auth_params")); } - this.isUserSignedIn = function() { - return localStorage.getItem("jwt"); - } - this.getAuthParamsForEmail = function(url, email, callback) { var requestUrl = url + "/auth/params"; var request = Restangular.oneUrl(requestUrl, requestUrl); @@ -103,7 +95,7 @@ angular.module('app.frontend') localStorage.setItem("jwt", response.token); localStorage.setItem("user", JSON.stringify(response.user)); localStorage.setItem("auth_params", JSON.stringify(_.omit(authParams, ["pw_nonce"]))); - syncManager.addKey(syncManager.SNKeyName, mk); + keyManager.addKey(SNKeyName, mk); syncManager.addStandardFileSyncProvider(url); } @@ -119,6 +111,7 @@ angular.module('app.frontend') callback(response); }.bind(this)) .catch(function(response){ + console.log("Registration error", response); callback(response.data); }) }.bind(this)); @@ -251,7 +244,7 @@ angular.module('app.frontend') items: items } - if(ek.name == syncManager.SNKeyName) { + if(ek.name == SNKeyName) { // auth params are only needed when encrypted with a standard file key data["auth_params"] = this.getAuthParams(); } @@ -263,34 +256,27 @@ angular.module('app.frontend') return JSON.parse(JSON.stringify(object)); } - - /* - Drafts - */ - - this.saveDraftToDisk = function(draft) { - localStorage.setItem("draft", JSON.stringify(draft)); - } - - this.clearDraft = function() { - localStorage.removeItem("draft"); - } - - this.getDraft = function() { - var draftString = localStorage.getItem("draft"); - if(!draftString || draftString == 'undefined') { - return null; - } - var jsonObj = _.merge({content_type: "Note"}, JSON.parse(draftString)); - return modelManager.createItem(jsonObj); - } - - this.signoutOfStandardFile = function(callback) { + this.signoutOfStandardFile = function(destroyAll, callback) { syncManager.removeStandardFileSyncProvider(); + if(destroyAll) { + this.destroyLocalData(callback); + } else { + localStorage.removeItem("user"); + localStorage.removeItem("jwt"); + localStorage.removeItem("server"); + localStorage.removeItem("auth_params"); + callback(); + } + } + + this.destroyLocalData = function(callback) { dbManager.clearAllItems(function(){ localStorage.clear(); - callback(); + if(callback) { + callback(); + } }); } + } }); diff --git a/app/assets/javascripts/app/services/directives/accountDataMenu.js b/app/assets/javascripts/app/services/directives/accountDataMenu.js index 91a032c2a..a1c4a9d11 100644 --- a/app/assets/javascripts/app/services/directives/accountDataMenu.js +++ b/app/assets/javascripts/app/services/directives/accountDataMenu.js @@ -10,6 +10,15 @@ class AccountDataMenu { controller($scope, apiController, modelManager) { 'ngInject'; + $scope.destroyLocalData = function() { + if(!confirm("Are you sure you want to end your session? This will delete all local items, sync providers, keys, and extensions.")) { + return; + } + + apiController.destroyLocalData(function(){ + window.location.reload(); + }) + } } } diff --git a/app/assets/javascripts/app/services/directives/accountSyncSection.js b/app/assets/javascripts/app/services/directives/accountSyncSection.js index e690e8940..ea839c34a 100644 --- a/app/assets/javascripts/app/services/directives/accountSyncSection.js +++ b/app/assets/javascripts/app/services/directives/accountSyncSection.js @@ -31,10 +31,22 @@ class AccountSyncSection { } $scope.removeSyncProvider = function(provider) { - syncManager.removeSyncProvider(provider); + if(provider.isStandardNotesAccount) { + alert("To remove your Standard Notes sync, sign out of your Standard Notes account.") + return; + } + + if(confirm("Are you sure you want to remove this sync provider?")) { + syncManager.removeSyncProvider(provider); + } } $scope.changeEncryptionKey = function(provider) { + if(provider.isStandardNotesAccount) { + alert("To change your encryption key for your Standard Notes account, you need to change your password. However, this functionality is not currently supported."); + return; + } + if(!confirm("Changing your encryption key will re-encrypt all your notes with the new key and sync them back to the server. This can take several minutes. We strongly recommend downloading a backup of your notes before continuing.")) { return; } @@ -46,6 +58,7 @@ class AccountSyncSection { $scope.saveKey = function(provider) { provider.showKeyForm = false; provider.keyName = provider.formData.keyName; + syncManager.didMakeChangesToSyncProviders(); if(provider.enabled) { syncManager.addAllDataAsNeedingSyncForProvider(provider); diff --git a/app/assets/javascripts/app/services/directives/accountVendorAccountSection.js b/app/assets/javascripts/app/services/directives/accountVendorAccountSection.js index 0b73f6c5e..789078619 100644 --- a/app/assets/javascripts/app/services/directives/accountVendorAccountSection.js +++ b/app/assets/javascripts/app/services/directives/accountVendorAccountSection.js @@ -7,10 +7,10 @@ class AccountVendorAccountSection { }; } - controller($scope, apiController, modelManager, $timeout, dbManager) { + controller($scope, apiController, modelManager, $timeout, dbManager, syncManager) { 'ngInject'; - $scope.loginData = {mergeLocal: true, url: apiController.defaultServerURL()}; + $scope.loginData = {mergeLocal: true, url: syncManager.serverURL()}; $scope.user = apiController.user; $scope.changePasswordPressed = function() { @@ -19,7 +19,7 @@ class AccountVendorAccountSection { $scope.signOutPressed = function() { $scope.showAccountMenu = false; - apiController.signoutOfStandardFile(function(){ + apiController.signoutOfStandardFile(false, function(){ window.location.reload(); }) } diff --git a/app/assets/javascripts/app/services/directives/importExportMenu.js b/app/assets/javascripts/app/services/directives/importExportMenu.js index 59654399a..fe96806e1 100644 --- a/app/assets/javascripts/app/services/directives/importExportMenu.js +++ b/app/assets/javascripts/app/services/directives/importExportMenu.js @@ -14,7 +14,7 @@ class ImportExportMenu { $scope.user = apiController.user; $scope.downloadDataArchive = function() { - if(!apiController.isUserSignedIn() && $scope.archiveFormData.encryption_type == 'ek') { + if($scope.archiveFormData.encryption_type == 'ek') { if(!$scope.archiveFormData.ek) { alert("You must set an encryption key to export the data encrypted.") return; diff --git a/app/assets/javascripts/app/services/sync/syncManager.js b/app/assets/javascripts/app/services/sync/syncManager.js index aec4a5ffd..d9603fedf 100644 --- a/app/assets/javascripts/app/services/sync/syncManager.js +++ b/app/assets/javascripts/app/services/sync/syncManager.js @@ -1,26 +1,38 @@ +export const SNKeyName = "Standard Notes Key"; + class SyncManager { - constructor(modelManager) { + constructor(modelManager, syncRunner) { this.modelManager = modelManager; - this.syncPerformer = new SyncPerformer() - this.SNKeyName = "Standard Notes Key"; + this.syncRunner = syncRunner; + this.syncRunner.setOnChangeProviderCallback(function(){ + this.didMakeChangesToSyncProviders(); + }.bind(this)) this.loadSyncProviders(); } get offline() { - return this.syncProviders.length == 0; + return this.enabledProviders.length == 0; + } + + serverURL() { + return localStorage.getItem("server") || "https://n3.standardnotes.org"; + } + + get enabledProviders() { + return this.syncProviders.filter(function(provider){return provider.enabled == true}); } sync(callback) { - this.syncPerformer.sync(this.syncProviders, callback); + this.syncRunner.sync(this.enabledProviders, callback); } syncWithProvider(provider, callback) { - this.syncPerformer.performSyncWithProvider(provider, callback); + this.syncRunner.performSyncWithProvider(provider, callback); } loadLocalItems(callback) { - this.syncPerformer.loadLocalItems(callback); + this.syncRunner.loadLocalItems(callback); } syncProviderForURL(url) { @@ -46,16 +58,17 @@ class SyncManager { } removeStandardFileSyncProvider() { - var sfProvider = _.find(this.syncProviders, {url: this.defaultServerURL() + "/items/sync"}) + var sfProvider = _.find(this.syncProviders, {url: this.serverURL() + "/items/sync"}) _.pull(this.syncProviders, sfProvider); this.didMakeChangesToSyncProviders(); } addStandardFileSyncProvider(url) { - var defaultProvider = new SyncProvider({url: url + "/items/sync", primary: this.syncProviders.length == 0}); - defaultProvider.keyName = this.SNKeyName; + var defaultProvider = new SyncProvider({url: url + "/items/sync", primary: this.enabledProviders.length == 0}); + defaultProvider.keyName = SNKeyName; defaultProvider.enabled = this.syncProviders.length == 0; this.syncProviders.push(defaultProvider); + this.didMakeChangesToSyncProviders(); return defaultProvider; } @@ -74,14 +87,15 @@ class SyncManager { this.syncProviders.push(new SyncProvider(p)); } } else { - // no providers saved, use default - if(this.isUserSignedIn()) { - var defaultProvider = this.addStandardFileSyncProvider(this.defaultServerURL()); + // no providers saved, this means migrating from old system to new + // check if user is signed in + if(this.offline && localStorage.getItem("user")) { + var defaultProvider = this.addStandardFileSyncProvider(this.serverURL()); defaultProvider.syncToken = localStorage.getItem("syncToken"); // migrate old key structure to new var mk = localStorage.getItem("mk"); if(mk) { - keyManager.addKey(this.SNKeyName, mk); + keyManager.addKey(SNKeyName, mk); localStorage.removeItem("mk"); } this.didMakeChangesToSyncProviders(); @@ -129,43 +143,3 @@ class SyncManager { } angular.module('app.frontend').service('syncManager', SyncManager); - -class SyncProvider { - - constructor(obj) { - this.encrypted = true; - _.merge(this, obj); - } - - addPendingItems(items) { - if(!this.pendingItems) { - this.pendingItems = []; - } - - this.pendingItems = this.pendingItems.concat(items); - } - - removePendingItems(items) { - this.pendingItems = _.difference(this.pendingItems, items); - } - - get status() { - if(!this.enabled) { - return null; - } - - if(this.primary) return "primary"; - else return "secondary"; - } - - asJSON() { - return { - enabled: this.enabled, - url: this.url, - primary: this.primary, - keyName: this.keyName, - syncToken: this.syncToken - } - } - -} diff --git a/app/assets/javascripts/app/services/sync/syncProvider.js b/app/assets/javascripts/app/services/sync/syncProvider.js new file mode 100644 index 000000000..79055803c --- /dev/null +++ b/app/assets/javascripts/app/services/sync/syncProvider.js @@ -0,0 +1,43 @@ +class SyncProvider { + + constructor(obj) { + this.encrypted = true; + _.merge(this, obj); + } + + addPendingItems(items) { + if(!this.pendingItems) { + this.pendingItems = []; + } + + this.pendingItems = this.pendingItems.concat(items); + } + + removePendingItems(items) { + this.pendingItems = _.difference(this.pendingItems, items); + } + + get isStandardNotesAccount() { + return this.keyName == SNKeyName; + } + + get status() { + if(!this.enabled) { + return null; + } + + if(this.primary) return "primary"; + else return "secondary"; + } + + asJSON() { + return { + enabled: this.enabled, + url: this.url, + primary: this.primary, + keyName: this.keyName, + syncToken: this.syncToken + } + } + +} diff --git a/app/assets/javascripts/app/services/sync/syncPerformer.js b/app/assets/javascripts/app/services/sync/syncRunner.js similarity index 82% rename from app/assets/javascripts/app/services/sync/syncPerformer.js rename to app/assets/javascripts/app/services/sync/syncRunner.js index e475dc701..0d1b809e9 100644 --- a/app/assets/javascripts/app/services/sync/syncPerformer.js +++ b/app/assets/javascripts/app/services/sync/syncRunner.js @@ -1,10 +1,12 @@ -class SyncPerformer { +class SyncRunner { - constructor(modelManager, dbManager, encryptionHelper, keyManager) { + constructor($rootScope, modelManager, dbManager, encryptionHelper, keyManager, Restangular) { + this.rootScope = $rootScope; this.modelManager = modelManager; this.dbManager = dbManager; this.encryptionHelper = encryptionHelper; this.keyManager = keyManager; + this.Restangular = Restangular; } setOnChangeProviderCallback(callback) { @@ -15,9 +17,14 @@ class SyncPerformer { this.onChangeProviderCallback(provider); } - writeItemsToLocalStorage(items, callback) { + writeItemsToLocalStorage(items, offlineOnly, callback) { var params = items.map(function(item) { - return this.paramsForItem(item, false, ["created_at", "updated_at", "dirty"], true) + var itemParams = new ItemParams(item, null); + itemParams = itemParams.paramsForLocalStorage(); + if(offlineOnly) { + delete itemParams.dirty; + } + return itemParams; }.bind(this)); this.dbManager.saveItems(params, callback); @@ -32,7 +39,8 @@ class SyncPerformer { } syncOffline(items, callback) { - this.writeItemsToLocalStorage(items, function(responseItems){ + console.log("Writing items offline", items); + this.writeItemsToLocalStorage(items, true, function(responseItems){ // delete anything needing to be deleted for(var item of items) { if(item.deleted) { @@ -57,10 +65,6 @@ class SyncPerformer { } for(let provider of providers) { - if(!provider.enabled) { - continue; - } - provider.addPendingItems(allDirtyItems); this.didMakeChangesToSyncProvider(provider); @@ -100,13 +104,13 @@ class SyncPerformer { provider.repeatOnCompletion = false; } - console.log("Syncing with provider", provider, subItems); + console.log("Syncing with provider:", provider.url, "items:", subItems.length); // Remove dirty items now. If this operation fails, we'll re-add them. // This allows us to queue changes on the same item provider.removePendingItems(subItems); - var request = Restangular.oneUrl(provider.url, provider.url); + var request = this.Restangular.oneUrl(provider.url, provider.url); request.limit = 150; request.items = _.map(subItems, function(item){ var itemParams = new ItemParams(item, provider.ek); @@ -119,12 +123,12 @@ class SyncPerformer { request.post().then(function(response) { - console.log("Sync completion", response); + console.log("Completed sync for provider:", provider.url, "Response:", response); provider.syncToken = response.sync_token; if(provider.primary) { - $rootScope.$broadcast("sync:updated_token", provider.syncToken); + this.rootScope.$broadcast("sync:updated_token", provider.syncToken); // handle cursor token (more results waiting, perform another sync) provider.cursorToken = response.cursor_token; @@ -136,8 +140,8 @@ class SyncPerformer { this.handleUnsavedItemsResponse(response.unsaved, provider) - this.writeItemsToLocalStorage(saved, null); - this.writeItemsToLocalStorage(retrieved, null); + this.writeItemsToLocalStorage(saved, false, null); + this.writeItemsToLocalStorage(retrieved, false, null); } provider.syncOpInProgress = false; @@ -159,7 +163,7 @@ class SyncPerformer { provider.syncOpInProgress = false; if(provider.primary) { - this.writeItemsToLocalStorage(allItems, null); + this.writeItemsToLocalStorage(allItems, false, null); } if(callback) { @@ -194,3 +198,5 @@ class SyncPerformer { return this.modelManager.mapResponseItemsToLocalModelsOmittingFields(responseItems, omitFields); } } + +angular.module('app.frontend').service('syncRunner', SyncRunner); diff --git a/app/assets/templates/frontend/directives/account-data-menu.html.haml b/app/assets/templates/frontend/directives/account-data-menu.html.haml index d4d7447a8..123ba1c9c 100644 --- a/app/assets/templates/frontend/directives/account-data-menu.html.haml +++ b/app/assets/templates/frontend/directives/account-data-menu.html.haml @@ -16,3 +16,5 @@ %section.account-item %h3{"ng-click" => "showIO = !showIO"} Import/Export %import-export-menu{"ng-if" => "showIO"} + + %a{"ng-click" => "destroyLocalData()"} Destroy all local data diff --git a/app/assets/templates/frontend/directives/account-sync-section.html.haml b/app/assets/templates/frontend/directives/account-sync-section.html.haml index afaed9829..b16189b48 100644 --- a/app/assets/templates/frontend/directives/account-sync-section.html.haml +++ b/app/assets/templates/frontend/directives/account-sync-section.html.haml @@ -1,18 +1,20 @@ .providers .provider{"ng-repeat" => "provider in syncProviders"} - .type {{provider.primary == null ? 'Not enabled' : (provider.primary ? 'Primary' : 'Secondary')}} + .type {{!provider.enabled ? 'Not enabled' : (provider.primary ? 'Primary' : 'Secondary')}} .key{"ng-if" => "provider.keyName"} Using key: {{provider.keyName}} .url {{provider.url}} .options - %div{"ng-if" => "!provider.enabled"} - %div{"ng-if" => "!provider.keyName || provider.showKeyForm"} + %div{"ng-if" => "!provider.keyName || provider.showKeyForm"} + %p %strong Choose encryption key: - %select{"ng-model" => "provider.formData.keyName"} - %option{"ng-repeat" => "key in keys", "ng-selected" => "{{key.name == provider.formData.keyName}}", "value" => "{{key.name}}"} - {{key.name}} - %button{"ng-click" => "saveKey(provider)"} Set + %select{"ng-model" => "provider.formData.keyName"} + %option{"ng-repeat" => "key in keys", "ng-selected" => "{{key.name == provider.formData.keyName}}", "value" => "{{key.name}}"} + {{key.name}} + %button{"ng-click" => "saveKey(provider)"} Set + + %div{"ng-if" => "!provider.enabled"} %button.light{"ng-click" => "enableSyncProvider(provider, true)"} Enable as Primary sync provider - %button.light{"ng-click" => "enableSyncProvider(provider, false)"} Enable as Secondary sync provider + %button.light{"ng-if" => "syncProviders.length > 1", "ng-click" => "enableSyncProvider(provider, false)"} Enable as Secondary sync provider %button.light{"ng-if" => "provider.keyName", "ng-click" => "changeEncryptionKey(provider)"} Change Encryption Key %button.light{"ng-click" => "removeSyncProvider(provider)"} Remove Provider diff --git a/app/assets/templates/frontend/directives/account-vendor-account-section.html.haml b/app/assets/templates/frontend/directives/account-vendor-account-section.html.haml index 3b0ac24de..1800ab39f 100644 --- a/app/assets/templates/frontend/directives/account-vendor-account-section.html.haml +++ b/app/assets/templates/frontend/directives/account-vendor-account-section.html.haml @@ -4,7 +4,8 @@ .server {{serverURL}} .links{"ng-if" => "user"} .link-item - %a{"ng-click" => "signOutPressed()"} Sign Out + %a{"ng-click" => "signOutPressed()"} Sign out + %p Note: Signing out does not delete your local items, extensions, and keys. .meta-container .title Local Encryption .desc Notes are encrypted locally before being sent to the server. Neither the server owner nor an intrusive entity can decrypt your locally encrypted notes. diff --git a/app/assets/templates/frontend/header.html.haml b/app/assets/templates/frontend/header.html.haml index 67106684f..7004a8fad 100644 --- a/app/assets/templates/frontend/header.html.haml +++ b/app/assets/templates/frontend/header.html.haml @@ -5,8 +5,7 @@ .items .item.account %a{"ng-click" => "ctrl.accountMenuPressed()"} Data - %account-data-menu - -# {"ng-if" => "ctrl.showAccountMenu"} + %account-data-menu{"ng-if" => "ctrl.showAccountMenu"} .item %a{"ng-click" => "ctrl.toggleExtensions()"} Extensions