From 48b4b0c3cfeae06055f7ca11781ec534284a7f52 Mon Sep 17 00:00:00 2001
From: blavenie <benoit.lavenier@e-is.pro>
Date: Thu, 19 Oct 2017 12:23:27 +0200
Subject: [PATCH] [enh] ES: Add a document search page [enh] docstats graphs: 
 add link to document search page

---
 www/index.html                                |   2 +
 www/js/services/http-services.js              |  83 ++++---
 www/plugins/es/i18n/locale-fr-FR.json         |  30 +++
 .../es/js/controllers/document-controllers.js | 213 ++++++++++++++++++
 .../controllers/notification-controllers.js   |   5 +
 www/plugins/es/js/plugin.js                   |   3 +-
 www/plugins/es/js/services.js                 |   3 +-
 .../es/js/services/blockchain-services.js     |   8 +-
 .../es/js/services/document-services.js       | 170 ++++++++++++++
 .../es/js/services/message-services.js        |   3 +-
 .../es/js/services/notification-services.js   |   6 +
 .../es/templates/document/item_document.html  |  49 ++++
 .../templates/document/items_documents.html   |  31 +++
 www/plugins/es/templates/document/lookup.html |  17 ++
 .../es/templates/document/lookup_form.html    | 100 ++++++++
 .../document/lookup_popover_actions.html      |  15 ++
 .../js/controllers/docstats-controllers.js    |  79 +++++--
 .../graph/templates/docstats/graph.html       |   5 +-
 18 files changed, 764 insertions(+), 58 deletions(-)
 create mode 100644 www/plugins/es/js/controllers/document-controllers.js
 create mode 100644 www/plugins/es/js/services/document-services.js
 create mode 100644 www/plugins/es/templates/document/item_document.html
 create mode 100644 www/plugins/es/templates/document/items_documents.html
 create mode 100644 www/plugins/es/templates/document/lookup.html
 create mode 100644 www/plugins/es/templates/document/lookup_form.html
 create mode 100644 www/plugins/es/templates/document/lookup_popover_actions.html

diff --git a/www/index.html b/www/index.html
index f8e4ed8f..8a836b7b 100644
--- a/www/index.html
+++ b/www/index.html
@@ -176,6 +176,7 @@
 <script src="dist/dist_js/plugins/es/js/services/wot-services.js"></script>
 <script src="dist/dist_js/plugins/es/js/services/tx-services.js"></script>
 <script src="dist/dist_js/plugins/es/js/services/geo-services.js"></script>
+<script src="dist/dist_js/plugins/es/js/services/document-services.js"></script>
 <script src="dist/dist_js/plugins/es/js/controllers/common-controllers.js"></script>
 <script src="dist/dist_js/plugins/es/js/controllers/app-controllers.js"></script>
 <script src="dist/dist_js/plugins/es/js/controllers/settings-controllers.js"></script>
@@ -191,6 +192,7 @@
 <script src="dist/dist_js/plugins/es/js/controllers/group-controllers.js"></script>
 <script src="dist/dist_js/plugins/es/js/controllers/invitation-controllers.js"></script>
 <script src="dist/dist_js/plugins/es/js/controllers/subscription-controllers.js"></script>
+<script src="dist/dist_js/plugins/es/js/controllers/document-controllers.js"></script>
 
 <!-- Graph plugin -->
 <!--removeIf(ubuntu)--> <!-- FIXME: issue #463 -->
diff --git a/www/js/services/http-services.js b/www/js/services/http-services.js
index e684b0e5..60827088 100644
--- a/www/js/services/http-services.js
+++ b/www/js/services/http-services.js
@@ -147,65 +147,76 @@ angular.module('cesium.http.services', ['cesium.cache.services'])
     };
   }
 
-  function ws(host, port, path, useSsl) {
+  function ws(host, port, path, useSsl, timeout) {
     if (!path) {
       console.error('calling csHttp.ws without path argument');
       throw 'calling csHttp.ws without path argument';
     }
     var uri = getWsUrl(host, port, path, useSsl);
-    var delegate = null;
-    var callbacks = [];
+    timeout = timeout || csSettings.data.timeout;
 
-    function _waitOpen() {
-      if (!delegate) throw new Error('Websocket not opened');
-      if (delegate.readyState == 1) {
-        return $q.when(delegate);
+    function _waitOpen(self) {
+      if (!self.delegate) throw new Error('Websocket not opened');
+      if (self.delegate.readyState == 1) {
+        return $q.when(self.delegate);
       }
-      if (delegate.readyState == 3) {
-        return $q.reject('Unable to connect to websocket ['+delegate.url+']');
+      if (self.delegate.readyState == 3) {
+        return $q.reject('Unable to connect to websocket ['+self.delegate.url+']');
       }
-      console.debug('[http] Waiting websocket ['+path+'] opening...');
-      return $timeout(_waitOpen, 200);
+      console.debug('[http] Waiting websocket ['+self.path+'] opening...');
+
+      if (self.waitDuration >= timeout) {
+        console.debug("[http] Will retry openning websocket later...");
+        self.waitRetryDelay = 2000; // 2 seconds
+      }
+
+      return $timeout(function(){
+        self.waitDuration += self.waitRetryDelay;
+        return _waitOpen(self);
+      }, self.waitRetryDelay);
     }
 
     function _open(self, callback, params) {
-      if (!delegate) {
+      if (!self.delegate) {
         self.path = path;
+        self.callbacks = [];
+        self.waitDuration = 0;
+        self.waitRetryDelay = 200;
 
         prepare(uri, params, {}, function(uri) {
-          delegate = new WebSocket(uri);
-          delegate.onerror = function(e) {
-            delegate.readyState=3;
+          self.delegate = new WebSocket(uri);
+          self.delegate.onerror = function(e) {
+            self.delegate.readyState=3;
           };
-          delegate.onmessage = function(e) {
+          self.delegate.onmessage = function(e) {
             var obj = JSON.parse(e.data);
-            _.forEach(callbacks, function(callback) {
+            _.forEach(self.callbacks, function(callback) {
               callback(obj);
             });
           };
-          delegate.onopen = function(e) {
-            console.debug('[http] Listening on websocket ['+path+']...');
+          self.delegate.onopen = function(e) {
+            console.debug('[http] Listening on websocket ['+self.path+']...');
             sockets.push(self);
-            delegate.openTime = new Date().getTime();
+            self.delegate.openTime = new Date().getTime();
           };
-          delegate.onclose = function() {
+          self.delegate.onclose = function() {
 
             // Remove from sockets arrays
-            var index = _.findIndex(sockets, function(socket){return socket.path === path;});
+            var index = _.findIndex(sockets, function(socket){return socket.path === self.path;});
             if (index >= 0) {
               sockets.splice(index,1);
             }
 
             // If close event comes from Cesium
-            if (delegate.closing) {
-              delegate = null;
+            if (self.delegate.closing) {
+              self.delegate = null;
             }
 
             // If unexpected close event, reopen the socket (fix #535)
             else {
-              console.debug('[http] Unexpected close of websocket ['+path+'] (open '+ (new Date().getTime() - delegate.openTime) +'ms ago): re-opening...');
+              console.debug('[http] Unexpected close of websocket ['+path+'] (open '+ (new Date().getTime() - self.delegate.openTime) +'ms ago): re-opening...');
 
-              delegate = null;
+              self.delegate = null;
 
               // Loop, but without the already registered callback
               _open(self, null, params);
@@ -213,8 +224,8 @@ angular.module('cesium.http.services', ['cesium.cache.services'])
           };
         });
       }
-      if (callback) callbacks.push(callback);
-      return _waitOpen();
+      if (callback) self.callbacks.push(callback);
+      return _waitOpen(self);
     }
 
     return {
@@ -225,17 +236,19 @@ angular.module('cesium.http.services', ['cesium.cache.services'])
         return _open(this, callback, params);
       },
       send: function(data) {
-        return _waitOpen()
+        var self = this;
+        return _waitOpen(self)
           .then(function(){
-            delegate.send(data);
+            self.delegate.send(data);
           });
       },
       close: function() {
-        if (delegate) {
-          delegate.closing = true;
-          console.debug('[http] Closing websocket ['+path+']...');
-          delegate.close();
-          callbacks = [];
+        var self = this;
+        if (self.delegate) {
+          self.delegate.closing = true;
+          console.debug('[http] Closing websocket ['+self.path+']...');
+          self.delegate.close();
+          self.callbacks = [];
         }
       }
     };
diff --git a/www/plugins/es/i18n/locale-fr-FR.json b/www/plugins/es/i18n/locale-fr-FR.json
index 87810374..36ff7cc5 100644
--- a/www/plugins/es/i18n/locale-fr-FR.json
+++ b/www/plugins/es/i18n/locale-fr-FR.json
@@ -413,6 +413,36 @@
       "PROVIDER": "Prestataire du service :"
     }
   },
+  "DOCUMENT": {
+    "HASH": "Hash: ",
+    "LOOKUP": {
+      "TITLE": "Recherche de documents",
+      "BTN_ACTIONS": "Actions",
+      "SEARCH_HELP": "issuer:AAA*, time:1508406169",
+      "LAST_DOCUMENTS": "Derniers documents :",
+      "SHOW_QUERY": "Voir la requête",
+      "HIDE_QUERY": "Masquer la requête",
+      "HEADER_TIME": "Date/Heure",
+      "HEADER_ISSUER": "Emetteur",
+      "HEADER_RECIPIENT": "Destinataire",
+      "READ": "Lu",
+      "POPOVER_ACTIONS": {
+        "TITLE": "Actions",
+        "REMOVE_ALL": "Supprimer ces documents..."
+      }
+    },
+    "INFO": {
+      "REMOVED": "Document supprimé"
+    },
+    "CONFIRM": {
+      "REMOVE": "Etes-vous sûr de vouloir <b>supprimer ce document</b> ?",
+      "REMOVE_ALL": "Etes-vous sûr de vouloir <b>supprimer ces documents</b> ?"
+    },
+    "ERROR": {
+      "REMOVE_FAILED": "Erreur lors de la suppression du document",
+      "REMOVE_ALL_FAILED": "Erreur lors de la suppression des documents"
+    }
+  },
   "ES_SETTINGS": {
     "PLUGIN_NAME": "Cesium+",
     "PLUGIN_NAME_HELP": "Profils, notifications, messages privés",
diff --git a/www/plugins/es/js/controllers/document-controllers.js b/www/plugins/es/js/controllers/document-controllers.js
new file mode 100644
index 00000000..a37042ea
--- /dev/null
+++ b/www/plugins/es/js/controllers/document-controllers.js
@@ -0,0 +1,213 @@
+angular.module('cesium.es.document.controllers', ['cesium.es.services'])
+
+  .config(function($stateProvider) {
+    'ngInject';
+
+    $stateProvider
+
+      .state('app.document_search', {
+        url: "/data/search/:index/:type?q",
+        views: {
+          'menuContent': {
+            templateUrl: "plugins/es/templates/document/lookup.html",
+            controller: 'ESDocumentLookupCtrl'
+          }
+        },
+        data: {
+          silentLocationChange: true
+        }
+      })
+    ;
+  })
+
+  .controller('ESDocumentLookupCtrl', ESDocumentLookupController)
+
+;
+
+function ESDocumentLookupController($scope, $ionicPopover, $location, $timeout,
+                                    csSettings, csWallet, UIUtils, esDocument) {
+  'ngInject';
+
+  $scope.search = {
+    loading: true,
+    hasMore: true,
+    text: undefined,
+    index: 'invitation',
+    type: 'certification',
+    results: undefined,
+    sort: 'time',
+    asc: false
+  };
+  $scope.entered = false;
+  $scope.searchTextId = 'documentSearchText';
+  $scope.ionItemClass = 'item-border-large';
+  $scope.defaultSizeLimit = UIUtils.screen.isSmall() ? 50 : 100;
+  $scope.helptipPrefix = 'helptip-document';
+
+  $scope.$on('$ionicView.enter', function(e, state) {
+
+    if (!$scope.entered) {
+      $scope.entered = true;
+      $scope.search.index = state.stateParams && state.stateParams.index || $scope.search.index;
+      $scope.search.type = state.stateParams && state.stateParams.type || $scope.search.type;
+      $scope.search.text = state.stateParams && state.stateParams.q || $scope.search.text;
+
+      $scope.search.last = !$scope.search.text;
+      $scope.load();
+    }
+    $scope.expertMode = angular.isDefined($scope.expertMode) ? $scope.expertMode : !UIUtils.screen.isSmall() && csSettings.data.expertMode;
+  });
+
+  $scope.load = function(size, offset) {
+    var options  = {
+      index: $scope.search.index,
+      type: $scope.search.type,
+      from: offset || 0,
+      size: size || $scope.defaultSizeLimit
+    };
+
+    // add sort
+    if ($scope.search.sort) {
+      options.sort = $scope.search.sort + ':' + (!$scope.search.asc ? "desc" : "asc");
+    }
+    else { // default sort
+      options.sort = "time:desc";
+    }
+
+    $scope.search.loading = true;
+
+    var searchFn =  $scope.search.last
+      ? esDocument.search(options)
+      : esDocument.searchText($scope.search.text||'', options);
+    return searchFn
+      .then(function(res) {
+        $scope.search.results = res.hits;
+        $scope.search.total = res.total;
+        $scope.search.took = res.took;
+
+        UIUtils.loading.hide();
+        $scope.search.loading = false;
+
+        if (res.hits && res.hits.length > 0) {
+          $scope.motion.show({selector: '.list .item.item-document'});
+          $scope.search.hasMore = res.total > $scope.search.results.length;
+        }
+        else {
+          $scope.search.hasMore = false;
+        }
+
+        $scope.$broadcast('$$rebind::rebind'); // notify binder
+      })
+      .catch(function(err) {
+        UIUtils.onError('DOCUMENT.ERROR.LOAD_DOCUMENTS_FAILED')(err);
+        $scope.search.results = [];
+        $scope.search.loading = false;
+      });
+  };
+
+  $scope.doSearchText = function() {
+    $scope.search.last = $scope.search.text ? false : true;
+    return $scope.load()
+      .then(function() {
+        // Update location href
+        $location.search({q: $scope.search.text}).replace();
+      });
+  };
+
+  $scope.doSearchLast = function() {
+    $scope.search.last = true;
+    $scope.search.text = undefined;
+    return $scope.load();
+  };
+
+  $scope.removeAll = function() {
+    $scope.hideActionsPopover();
+    if (!$scope.search.results || !$scope.search.results.length) return;
+
+    return UIUtils.alert.confirm('DOCUMENT.CONFIRM.REMOVE_ALL')
+      .then(function(confirm) {
+        if (confirm) {
+          return esDocument.removeAll($scope.search.results)
+            .catch(UIUtils.onError('DOCUMENT.ERROR.REMOVE_ALL_FAILED'));
+        }
+      });
+  };
+
+  $scope.remove = function(index) {
+    var doc = $scope.search.results[index];
+    if (!doc) return;
+
+    UIUtils.alert.confirm('DOCUMENT.CONFIRM.REMOVE')
+      .then(function(confirm) {
+        if (confirm) {
+          esDocument.remove(doc)
+            .then(function () {
+              $scope.search.results.splice(index,1); // remove from messages array
+              UIUtils.toast.show('DOCUMENT.INFO.REMOVED');
+            })
+            .catch(UIUtils.onError('MESSAGE.ERROR.REMOVE_FAILED'));
+        }
+      });
+  };
+
+  $scope.selectDocument = function(doc) {
+    console.debug("Selected document: ", doc);
+  };
+
+  $scope.toggleSort = function(sort){
+    if ($scope.search.sort === sort && !$scope.search.asc) {
+      $scope.search.asc = undefined;
+      $scope.search.sort = undefined;
+    }
+    else {
+      $scope.search.asc = ($scope.search.sort === sort) ? !$scope.search.asc : true;
+      $scope.search.sort = sort;
+    }
+    $scope.load();
+  };
+
+  /* -- Modals -- */
+
+  /* -- Popover -- */
+
+  $scope.showActionsPopover = function(event) {
+    if (!$scope.actionsPopover) {
+      $ionicPopover.fromTemplateUrl('plugins/es/templates/document/lookup_popover_actions.html', {
+        scope: $scope
+      }).then(function(popover) {
+        $scope.actionsPopover = popover;
+        //Cleanup the popover when we're done with it!
+        $scope.$on('$destroy', function() {
+          $scope.actionsPopover.remove();
+        });
+        $scope.actionsPopover.show(event);
+      });
+    }
+    else {
+      $scope.actionsPopover.show(event);
+    }
+  };
+
+  $scope.hideActionsPopover = function() {
+    if ($scope.actionsPopover) {
+      $scope.actionsPopover.hide();
+    }
+  };
+
+  /* -- watch events -- */
+
+  // Watch unauth
+  $scope.onUnauth = function() {
+    // Reset all data
+    $scope.search.results = undefined;
+    $scope.search.loading = false;
+    $scope.entered = false;
+  };
+  csWallet.api.data.on.unauth($scope, $scope.onUnauth);
+
+  // for DEV only
+  /*$timeout(function() {
+    // launch default action fo DEV
+   }, 900);
+   */
+}
diff --git a/www/plugins/es/js/controllers/notification-controllers.js b/www/plugins/es/js/controllers/notification-controllers.js
index d84635da..4568da45 100644
--- a/www/plugins/es/js/controllers/notification-controllers.js
+++ b/www/plugins/es/js/controllers/notification-controllers.js
@@ -62,6 +62,11 @@ function NotificationsController($scope, $rootScope, $ionicPopover, $state, $tim
   });
 
   $scope.load = function(from, size) {
+    if (!csWallet.data.pubkey) {
+      $scope.search.loading = true;
+      return;
+    }
+
     var options = angular.copy($scope.search.options);
     options.from = options.from || from || 0;
     options.size = options.size || size || defaultSearchLimit;
diff --git a/www/plugins/es/js/plugin.js b/www/plugins/es/js/plugin.js
index a31bb56c..c1b1dc07 100644
--- a/www/plugins/es/js/plugin.js
+++ b/www/plugins/es/js/plugin.js
@@ -17,6 +17,7 @@ angular.module('cesium.es.plugin', [
     'cesium.es.registry.controllers',
     'cesium.es.group.controllers',
     'cesium.es.invitation.controllers',
-    'cesium.es.subscription.controllers'
+    'cesium.es.subscription.controllers',
+    'cesium.es.document.controllers'
   ])
 ;
diff --git a/www/plugins/es/js/services.js b/www/plugins/es/js/services.js
index 4ff9ef1a..a4a8f888 100644
--- a/www/plugins/es/js/services.js
+++ b/www/plugins/es/js/services.js
@@ -18,6 +18,7 @@ angular.module('cesium.es.services', [
     'cesium.es.subscription.services',
     'cesium.es.wot.services',
     'cesium.es.tx.services',
-    'cesium.es.geo.services'
+    'cesium.es.geo.services',
+    'cesium.es.document.services'
   ])
 ;
diff --git a/www/plugins/es/js/services/blockchain-services.js b/www/plugins/es/js/services/blockchain-services.js
index 3e87c427..dd768ba3 100644
--- a/www/plugins/es/js/services/blockchain-services.js
+++ b/www/plugins/es/js/services/blockchain-services.js
@@ -64,7 +64,7 @@ angular.module('cesium.es.blockchain.services', ['cesium.services', 'cesium.es.h
       options.cleanData = angular.isDefined(options.cleanData) ? options.cleanData : true;
 
       var hasExcludedCurrent = false;
-      var hits = res && res.hits ? res.hits.hits.reduce(function(res, hit) {
+      var hits = (res && res.hits && res.hits.hits || []).reduce(function(res, hit) {
         if (hit._id == 'current' && options.excludeCurrent) {
           hasExcludedCurrent = true;
           return res;
@@ -75,15 +75,13 @@ angular.module('cesium.es.blockchain.services', ['cesium.services', 'cesium.es.h
           block.cleanData(); // release data's arrays
         }
         return res.concat(block);
-      }, []) : [];
-      var result = {
+      }, []);
+      return {
         hits: hits,
         took: res.took,
         total: res && res.hits && res.hits.total ? (
           hasExcludedCurrent ? res.hits.total-1 : res.hits.total) : 0
       };
-
-      return result;
     };
 
     exports.block.search = function(currency, options) {
diff --git a/www/plugins/es/js/services/document-services.js b/www/plugins/es/js/services/document-services.js
new file mode 100644
index 00000000..24bf8ece
--- /dev/null
+++ b/www/plugins/es/js/services/document-services.js
@@ -0,0 +1,170 @@
+angular.module('cesium.es.document.services', ['ngResource', 'cesium.platform', 'cesium.es.http.services'])
+  .config(function(PluginServiceProvider, csConfig) {
+    'ngInject';
+
+    var enable = csConfig.plugins && csConfig.plugins.es;
+    if (enable) {
+      // Will force to load this service
+      PluginServiceProvider.registerEagerLoadingService('esDocument');
+    }
+
+  })
+
+  .factory('esDocument', function($q, $rootScope, $timeout, UIUtils, Api, CryptoUtils,
+                                 csPlatform, csConfig, csSettings, csWot, csWallet, esHttp) {
+    'ngInject';
+
+    var
+      constants = {
+        DEFAULT_LOAD_SIZE: 40
+      },
+      fields = {
+        commons: ["issuer", "pubkey", "hash", "time", "recipient", "nonce", "read_signature"],
+        peer: ["*"]
+      },
+      raw = {
+        search: esHttp.post('/:index/:type/_search'),
+        searchText: esHttp.get('/:index/:type/_search?q=:text')
+      };
+
+    function _readSearchHits(res, options) {
+      options.issuerField = options.issuerField || 'pubkey';
+
+      var hits = (res && res.hits && res.hits.hits || []).reduce(function(res, hit) {
+        var doc = hit._source || {};
+        doc.index = hit._index;
+        doc.type = hit._type;
+        doc.id = hit._id;
+        doc.pubkey = doc.issuer || options.issuerField && doc[options.issuerField]; // need to call csWot.extendAll()
+        doc.time = doc.time || options.getTimeFunction && options.getTimeFunction(doc);
+        return res.concat(doc);
+      }, []);
+
+
+      var recipients = hits.reduce(function(res, doc) {
+        if (doc.recipient) {
+          doc.recipient = {
+            pubkey: doc.recipient
+          };
+          return res.concat(doc.recipient);
+        }
+        return res;
+      }, []);
+
+      return csWot.extendAll(hits.concat(recipients))
+        .then(function() {
+          return  {
+            hits: hits,
+            took: res.took,
+            total: res && res.hits && res.hits.total || 0
+          };
+        });
+    }
+
+    function search(options) {
+      options = options || {};
+
+      if (options.type == 'peer') {
+        if (!options.sort) {
+          options.sort = 'stats.medianTime:desc';
+        }
+        else {
+          var sortParts = options.sort.split(':');
+          var side = sortParts.length > 1 ? sortParts[1] : 'desc';
+
+          options.sort = [
+            {'stats.medianTime': {
+              nested_path : 'stats',
+              order: side
+            }}
+          ];
+          options._source = fields.peer;
+          options.getTimeFunction = function(doc) {
+            return doc.stats && doc.stats.medianTime;
+          };
+        }
+      }
+
+      if (!options || !options.index || !options.type) throw new Error('Missing mandatory options [index, type]');
+      var request = {
+        from: options.from || 0,
+        size: options.size || constants.DEFAULT_LOAD_SIZE,
+        sort: options.sort || 'time:desc',
+        _source: options._source || fields.commons
+      };
+      if (options.query) {
+        request.query = options.query;
+      }
+
+      return raw.search(request, {
+          index: options.index,
+          type: options.type
+        })
+        .then(function(res) {
+          return _readSearchHits(res, options);
+        });
+    }
+
+    function searchText(queryString, options) {
+
+      options = options || {};
+
+      var request = {
+        text: queryString,
+        index: options.index || 'user',
+        type: options.type || 'event',
+        from: options.from || 0,
+        size: options.size || constants.DEFAULT_LOAD_SIZE,
+        sort: options.sort || 'time:desc',
+        _source: options._source || fields.commons.join(',')
+      };
+
+      console.debug('[ES] [wallet] [document] [{0}/{1}] Loading documents...'.format(
+        options.index,
+        options.type
+      ));
+      var now = new Date().getTime();
+
+      return raw.searchText(request)
+        .then(function(res) {
+          return _readSearchHits(res, options);
+        })
+        .then(function(res) {
+          console.debug('[ES] [document] [{0}/{1}] Loading {2} documents in {3}ms'.format(
+            options.index,
+            options.type,
+            res && res.hits && res.hits.length || 0,
+            new Date().getTime() - now
+          ));
+          return res;
+        });
+    }
+
+    function remove(document) {
+      if (document || !document.index || !document.type || !document.id) return;
+      return esHttp.record.remove(document.index, document.type)(document.id);
+    }
+
+    function removeAll(documents) {
+      if (!documents || !documents.length) return;
+
+      return csWallet.auth()
+        .then(function(walletData) {
+          // Remove each doc
+          return $q.all(documents.reduce(function (res, doc) {
+            return res.concat(esHttp.record.remove(doc.index, doc.type)(doc.id, walletData));
+          }, []));
+        });
+    }
+
+    return {
+      search: search,
+      searchText: searchText,
+      remove: remove,
+      removeAll: removeAll,
+      fields: {
+        commons: fields.commons
+      }
+    };
+  })
+;
diff --git a/www/plugins/es/js/services/message-services.js b/www/plugins/es/js/services/message-services.js
index 3469630d..7016878c 100644
--- a/www/plugins/es/js/services/message-services.js
+++ b/www/plugins/es/js/services/message-services.js
@@ -25,6 +25,7 @@ angular.module('cesium.es.message.services', ['ngResource', 'cesium.platform',
       },
       raw = {
         postSearch: esHttp.post('/message/inbox/_search'),
+        postSearchByType: esHttp.post('/message/:type/_search'),
         getByTypeAndId : esHttp.get('/message/:type/:id'),
         postReadById: esHttp.post('/message/inbox/:id/_read')
       },
@@ -200,7 +201,7 @@ angular.module('cesium.es.message.services', ['ngResource', 'cesium.platform',
         request.query = {bool: {filter: {term: {issuer: pubkey}}}};
       }
 
-      return esHttp.post('/message/:type/_search')(request, {type: options.type})
+      return raw.postSearchByType(request, {type: options.type})
         .then(function(res) {
           if (!res || !res.hits || !res.hits.total) {
             return [];
diff --git a/www/plugins/es/js/services/notification-services.js b/www/plugins/es/js/services/notification-services.js
index ff68c042..42dc11ec 100644
--- a/www/plugins/es/js/services/notification-services.js
+++ b/www/plugins/es/js/services/notification-services.js
@@ -91,6 +91,9 @@ angular.module('cesium.es.notification.services', ['cesium.platform', 'cesium.es
 
   // Load unread notifications count
   function loadUnreadNotificationsCount(pubkey, options) {
+    if (!pubkey) {
+      return $q.reject('[ES] [notification] Unable to load - missing pubkey');
+    }
     var request = {
       query: createFilterQuery(pubkey, options)
     };
@@ -104,6 +107,9 @@ angular.module('cesium.es.notification.services', ['cesium.platform', 'cesium.es
 
   // Load user notifications
   function loadNotifications(pubkey, options) {
+    if (!pubkey) {
+      return $q.reject('[ES] [notification] Unable to load - missing pubkey');
+    }
     options = options || {};
     options.from = options.from || 0;
     options.size = options.size || constants.DEFAULT_LOAD_SIZE;
diff --git a/www/plugins/es/templates/document/item_document.html b/www/plugins/es/templates/document/item_document.html
new file mode 100644
index 00000000..b2698d1e
--- /dev/null
+++ b/www/plugins/es/templates/document/item_document.html
@@ -0,0 +1,49 @@
+<ion-item id="doc-{{::doc.id}}"
+          class="item item-document item-icon-left ink {{::ionItemClass}} no-padding-top no-padding-bottom"
+          ng-class="{{::ionItemClass}}"
+          ng-click="selectDocument(doc)">
+
+  <i class="icon ion-document stable" ng-if=":rebind:!doc.avatar"></i>
+  <i class="avatar" ng-if=":rebind:doc.avatar" style="background-image: url('{{:rebind:doc.avatar.src}}')"></i>
+
+  <div class="row no-padding">
+    <div class="col">
+      <h3 class="dark">
+        <i class="ion-locked" ng-if=":rebind:doc.nonce"></i>
+        {{:rebind:doc.time|formatDate}}</h3>
+      <h4 class="gray">{{:rebind:'DOCUMENT.HASH'|translate}} {{:rebind:doc.hash|formatHash}}</h4>
+    </div>
+
+    <div class="col">
+      <h3>
+        <a ui-sref="app.wot_identity({pubkey: doc.pubkey, uid: doc.uid})">
+          <span class="gray">
+            <i class="ion-key"></i> {{:rebind:doc.pubkey|formatPubkey}}
+          </span>
+          <span class="positive" ng-if=":rebind:doc.uid">
+            <i class="ion-person"></i> {{:rebind:doc.name||doc.uid}}
+          </span>
+        </a>
+      </h3>
+    </div>
+
+    <div class="col">
+      <h3 ng-if=":rebind:doc.recipient">
+          <a ui-sref="app.wot_identity({pubkey: doc.recipient.pubkey, uid: doc.recipient.uid})">
+          <span class="gray">
+            <i class="ion-key"></i> {{:rebind:doc.recipient.pubkey|formatPubkey}}
+          </span>
+            <span class="positive" ng-if=":rebind:doc.recipient.uid">
+            <i class="ion-person"></i> {{:rebind:doc.recipient.name||doc.recipient.uid}}
+          </span>
+          </a>
+      </h3>
+      <h4 class="gray" ng-if=":rebind:doc.read_signature">
+        <i class="ion-checkmark"></i>
+        <span translate>DOCUMENT.LOOKUP.READ</span>
+      </h4>
+
+    </div>
+
+  </div>
+</ion-item>
diff --git a/www/plugins/es/templates/document/items_documents.html b/www/plugins/es/templates/document/items_documents.html
new file mode 100644
index 00000000..6306e779
--- /dev/null
+++ b/www/plugins/es/templates/document/items_documents.html
@@ -0,0 +1,31 @@
+
+<div class="item row row-header done in hidden-xs hidden-sm">
+
+  <a class="no-padding dark col col-header"
+     ng-if=":rebind:expertMode"
+     ng-click="toggleSort('time')">
+    <cs-sort-icon asc="search.asc" sort="search.sort" toggle="'time'"></cs-sort-icon>
+    {{'DOCUMENT.LOOKUP.HEADER_TIME' | translate}}
+  </a>
+  <a class="no-padding dark col col-header"
+     ng-if=":rebind:expertMode"
+     ng-click="toggleSort('issuer')">
+    <cs-sort-icon asc="search.asc" sort="search.sort" toggle="'issuer'"></cs-sort-icon>
+    {{'DOCUMENT.LOOKUP.HEADER_ISSUER' | translate}}
+  </a>
+  <a class="no-padding dark col col-header"
+     ng-if=":rebind:expertMode"
+     ng-click="toggleSort('recipient')">
+    <cs-sort-icon asc="search.asc" sort="search.sort" toggle="'recipient'"></cs-sort-icon>
+    {{'DOCUMENT.LOOKUP.HEADER_RECIPIENT' | translate}}
+  </a>
+</div>
+
+<div class="padding gray" ng-if=":rebind:!search.loading && !search.results.length" translate>
+  COMMON.SEARCH_NO_RESULT
+</div>
+
+<!-- for each doc -->
+<ng-repeat ng-repeat="doc in :rebind:search.results track by doc.id"
+           ng-include="'plugins/es/templates/document/item_document.html'">
+</ng-repeat>
diff --git a/www/plugins/es/templates/document/lookup.html b/www/plugins/es/templates/document/lookup.html
new file mode 100644
index 00000000..26b19071
--- /dev/null
+++ b/www/plugins/es/templates/document/lookup.html
@@ -0,0 +1,17 @@
+<ion-view>
+  <ion-nav-title>
+    <span translate>DOCUMENT.LOOKUP.TITLE</span>
+  </ion-nav-title>
+
+  <ion-nav-buttons side="secondary">
+
+    <button class="button button-icon button-clear icon ion-android-more-vertical visible-xs visible-sm" ng-click="showActionsPopover($event)">
+    </button>
+
+  </ion-nav-buttons>
+
+  <ion-content class="padding no-padding-xs" scroll="true">
+
+    <ng-include src="'plugins/es/templates/document/lookup_form.html'"></ng-include>
+  </ion-content>
+</ion-view>
diff --git a/www/plugins/es/templates/document/lookup_form.html b/www/plugins/es/templates/document/lookup_form.html
new file mode 100644
index 00000000..b0d2f504
--- /dev/null
+++ b/www/plugins/es/templates/document/lookup_form.html
@@ -0,0 +1,100 @@
+<div class="lookupForm">
+
+
+  <div class="item no-padding">
+
+    <!--<div class="button button-small button-text button-stable button-icon-event padding no-padding-right ink"
+         ng-repeat="filter in search.filters" ng-if="filter">
+      <span ng-bind-html="'DOCUMENT.LOOKUP.TX_SEARCH_FILTER.'+filter.type|translate:filter"></span>
+      <i class="icon ion-close" ng-click="itemRemove($index)"></i>
+
+    </div>-->
+
+    <label class="item-input">
+      <i class="icon ion-search placeholder-icon"></i>
+      <input type="text"
+             class="visible-xs visible-sm"
+             placeholder="{{'DOCUMENT.LOOKUP.SEARCH_HELP'|translate}}"
+             ng-model="search.text"
+             ng-model-options="{ debounce: 650 }"
+             ng-change="doSearchText()">
+      <input type="text"
+             class="hidden-xs hidden-sm"
+             id="{{searchTextId}}" placeholder="{{'DOCUMENT.LOOKUP.SEARCH_HELP'|translate}}"
+             ng-model="search.text"
+             on-return="doSearchText()">
+      <div class="helptip-anchor-center">
+        <a id="{{helptipPrefix}}-search-text"></a>
+      </div>
+
+    </label>
+  </div>
+
+
+  <div class="padding-top padding-xs" style="display: block; height: 60px;">
+    <div class="pull-left">
+      <h4
+        ng-if="search.last" translate>
+        DOCUMENT.LOOKUP.LAST_DOCUMENTS
+      </h4>
+      <h4 ng-if="!search.last">
+        {{'COMMON.RESULTS_LIST'|translate}}
+      </h4>
+      <h5 class="dark" ng-if="!search.loading && search.total">
+        <span translate="COMMON.RESULTS_COUNT" translate-values="{count: search.total}"></span>
+        <small class="gray" ng-if=":rebind:search.took && expertMode">
+          - {{:rebind:'COMMON.EXECUTION_TIME'|translate: {duration: search.took} }}
+        </small>
+        <small class="gray" ng-if=":rebind:expertMode && search.filters && search.filters.length">
+          - <a ng-click="toggleShowQuery()"
+            ng-if="!showQuery" >
+            <span translate>DOCUMENT.LOOKUP.SHOW_QUERY</span>
+            <i class="icon ion-arrow-down-b gray"></i>
+          </a>
+          <a ng-click="toggleShowQuery()"
+             ng-if="showQuery" >
+            <span translate>DOCUMENT.LOOKUP.HIDE_QUERY</span>
+             <i class="icon ion-arrow-up-b gray"></i>
+          </a>
+        </small>
+      </h5>
+      <h5 class="gray" ng-if="search.loading" >
+        <ion-spinner class="icon ion-spinner-small" icon="android"></ion-spinner>
+        <span translate>COMMON.SEARCHING</span>
+        <br/>
+      </h5>
+    </div>
+
+    <div class=" pull-right hidden-xs hidden-sm">
+      <a class="button button-text button-small ink"
+         ng-click="showActionsPopover($event)">
+        {{'DOCUMENT.LOOKUP.BTN_ACTIONS' | translate}}
+        <i class="icon ion-arrow-down-b"></i>
+      </a>
+      &nbsp;
+      <button class="button button-small button-stable ink"
+              ng-click="doSearchText()">
+        {{'COMMON.BTN_SEARCH' | translate:search}}
+      </button>
+    </div>
+  </div>
+
+  <div class="item no-border no-padding" ng-if=":rebind:search.filters && search.filters.length && expertMode">
+    <small class="no-padding no-margin" ng-if="showQuery">
+      <span class="gray text-wrap dark">{{:rebind:search.query}}</span>
+    </small>
+  </div>
+
+  <ion-list class="list" ng-class="::motion.ionListClass">
+
+    <ng-include src="'plugins/es/templates/document/items_documents.html'"></ng-include>
+
+  </ion-list>
+
+  <ion-infinite-scroll
+    ng-if="search.hasMore"
+    spinner="android"
+    on-infinite="showMore()"
+    distance="1%">
+  </ion-infinite-scroll>
+
diff --git a/www/plugins/es/templates/document/lookup_popover_actions.html b/www/plugins/es/templates/document/lookup_popover_actions.html
new file mode 100644
index 00000000..7d9dba60
--- /dev/null
+++ b/www/plugins/es/templates/document/lookup_popover_actions.html
@@ -0,0 +1,15 @@
+<ion-popover-view class="fit has-header">
+  <ion-header-bar>
+    <h1 class="title" translate>DOCUMENT.LOOKUP.POPOVER_ACTIONS.TITLE</h1>
+  </ion-header-bar>
+  <ion-content scroll="false">
+    <div class="list item-text-wrap">
+      <a class="item item-icon-left assertive ink"
+         ng-class="{'gray': !search.total}"
+         ng-click="removeAll()">
+        <i class="icon ion-trash-a"></i>
+        {{'DOCUMENT.LOOKUP.POPOVER_ACTIONS.REMOVE_ALL' | translate}}
+      </a>
+    </div>
+  </ion-content>
+</ion-popover-view>
diff --git a/www/plugins/graph/js/controllers/docstats-controllers.js b/www/plugins/graph/js/controllers/docstats-controllers.js
index d868d110..a3c20f9e 100644
--- a/www/plugins/graph/js/controllers/docstats-controllers.js
+++ b/www/plugins/graph/js/controllers/docstats-controllers.js
@@ -25,7 +25,7 @@ angular.module('cesium.graph.docstats.controllers', ['chart.js', 'cesium.graph.s
   .controller('GpDocStatsCtrl', GpDocStatsController)
 ;
 
-function GpDocStatsController($scope, $controller, $q, $translate, gpColor, gpData, $filter) {
+function GpDocStatsController($scope, $state, $controller, $q, $translate, gpColor, gpData, $filter) {
   'ngInject';
 
   // Initialize the super class and extend it.
@@ -33,6 +33,7 @@ function GpDocStatsController($scope, $controller, $q, $translate, gpColor, gpDa
 
   $scope.hiddenDatasets = [];
 
+  $scope.chartIdPrefix = 'docstats-chart-';
   $scope.charts = [
 
     // User count
@@ -44,13 +45,21 @@ function GpDocStatsController($scope, $controller, $q, $translate, gpColor, gpDa
           key: 'user_profile',
           label: 'GRAPH.DOC_STATS.USER.USER_PROFILE',
           color: gpColor.rgba.royal(1),
-          pointHoverBackgroundColor: gpColor.rgba.royal(1)
+          pointHoverBackgroundColor: gpColor.rgba.royal(1),
+          clickState: {
+            name: 'app.document_search',
+            params: {index:'user', type: 'profile'}
+          }
         },
         {
           key: 'user_settings',
           label: 'GRAPH.DOC_STATS.USER.USER_SETTINGS',
           color: gpColor.rgba.gray(0.5),
-          pointHoverBackgroundColor: gpColor.rgba.gray(1)
+          pointHoverBackgroundColor: gpColor.rgba.gray(1),
+          clickState: {
+            name: 'app.document_search',
+            params: {index:'user', type: 'settings'}
+          }
         }
       ]
     },
@@ -64,19 +73,31 @@ function GpDocStatsController($scope, $controller, $q, $translate, gpColor, gpDa
           key: 'message_inbox',
           label: 'GRAPH.DOC_STATS.MESSAGE.MESSAGE_INBOX',
           color: gpColor.rgba.royal(1),
-          pointHoverBackgroundColor: gpColor.rgba.royal(1)
+          pointHoverBackgroundColor: gpColor.rgba.royal(1),
+          clickState: {
+            name: 'app.document_search',
+            params: {index:'message', type: 'inbox'}
+          }
         },
         {
           key: 'message_outbox',
           label: 'GRAPH.DOC_STATS.MESSAGE.MESSAGE_OUTBOX',
           color: gpColor.rgba.calm(1),
-          pointHoverBackgroundColor: gpColor.rgba.calm(1)
+          pointHoverBackgroundColor: gpColor.rgba.calm(1),
+          clickState: {
+            name: 'app.document_search',
+            params: {index:'message', type: 'outbox'}
+          }
         },
         {
           key: 'invitation_certification',
           label: 'GRAPH.DOC_STATS.MESSAGE.INVITATION_CERTIFICATION',
           color: gpColor.rgba.gray(0.5),
-          pointHoverBackgroundColor: gpColor.rgba.gray(1)
+          pointHoverBackgroundColor: gpColor.rgba.gray(1),
+          clickState: {
+            name: 'app.document_search',
+            params: {index:'invitation', type: 'certification'}
+          }
         }
       ]
     },
@@ -90,19 +111,31 @@ function GpDocStatsController($scope, $controller, $q, $translate, gpColor, gpDa
           key: 'page_record',
           label: 'GRAPH.DOC_STATS.SOCIAL.PAGE_RECORD',
           color: gpColor.rgba.royal(1),
-          pointHoverBackgroundColor: gpColor.rgba.royal(1)
+          pointHoverBackgroundColor: gpColor.rgba.royal(1),
+          clickState: {
+            name: 'app.document_search',
+            params: {index:'page', type: 'record'}
+          }
         },
         {
           key: 'group_record',
           label: 'GRAPH.DOC_STATS.SOCIAL.GROUP_RECORD',
           color: gpColor.rgba.calm(1),
-          pointHoverBackgroundColor: gpColor.rgba.calm(1)
+          pointHoverBackgroundColor: gpColor.rgba.calm(1),
+          clickState: {
+            name: 'app.document_search',
+            params: {index:'group', type: 'record'}
+          }
         },
         {
           key: 'page_comment',
           label: 'GRAPH.DOC_STATS.SOCIAL.PAGE_COMMENT',
           color: gpColor.rgba.gray(0.5),
-          pointHoverBackgroundColor: gpColor.rgba.gray(1)
+          pointHoverBackgroundColor: gpColor.rgba.gray(1),
+          clickState: {
+            name: 'app.document_search',
+            params: {index:'page', type: 'comment'}
+          }
         }
       ]
     },
@@ -116,7 +149,11 @@ function GpDocStatsController($scope, $controller, $q, $translate, gpColor, gpDa
           key: 'history_delete',
           label: 'GRAPH.DOC_STATS.OTHER.HISTORY_DELETE',
           color: gpColor.rgba.gray(0.5),
-          pointHoverBackgroundColor: gpColor.rgba.gray(1)
+          pointHoverBackgroundColor: gpColor.rgba.gray(1),
+          clickState: {
+            name: 'app.document_search',
+            params: {index:'history', type: 'delete'}
+          }
         }
       ]
     }
@@ -237,9 +274,25 @@ function GpDocStatsController($scope, $controller, $q, $translate, gpColor, gpDa
 
   $scope.onChartClick = function(data, e, item) {
     if (!item) return;
-    console.log('Click on item index='+ item._index);
-    var from = $scope.times[item._index];
-    var to = moment.unix(from).utc().add(1, $scope.formData.rangeDuration).unix();
+    var chart = _.find($scope.charts , function(chart) {
+      return ($scope.chartIdPrefix  + chart.id) == item._chart.canvas.id;
+    });
+
+    var serie = chart.series[item._datasetIndex];
+
+    if (serie && serie.clickState && serie.clickState.name) {
+      var stateParams = serie.clickState.params ? angular.copy(serie.clickState.params) : {};
+
+      // Compute query
+      var from = $scope.times[item._index];
+      var to = moment.unix(from).utc().add(1, $scope.formData.rangeDuration).unix();
+      stateParams.q = 'time:>={0} AND time:<{1}'.format(from, to);
+
+      return $state.go(serie.clickState.name, stateParams);
+    }
+    else {
+      console.debug('Click on item index={0} on range [{1},{2}]'.format(item._index, from, to));
+    }
   };
 
 
diff --git a/www/plugins/graph/templates/docstats/graph.html b/www/plugins/graph/templates/docstats/graph.html
index 724c4418..8cdde39e 100644
--- a/www/plugins/graph/templates/docstats/graph.html
+++ b/www/plugins/graph/templates/docstats/graph.html
@@ -9,14 +9,15 @@
     </button>
   </div>
 
-  <canvas id="docstats-chart-{{chart.id}}"
+  <canvas id="{{::chartIdPrefix}}{{chart.id}}"
           class="chart-line"
           height="{{height}}"
           width="{{width}}"
           chart-data="chart.data"
           chart-labels="labels"
           chart-dataset-override="chart.datasetOverride"
-          chart-options="chart.options">
+          chart-options="chart.options"
+          chart-click="onChartClick">
   </canvas>
 
   <ng-include src="'plugins/graph/templates/common/graph_range_bar.html'"></ng-include>
-- 
GitLab