From 9de6a26a221d1f874e1e036a87512484368a6313 Mon Sep 17 00:00:00 2001
From: Steffen van Bergerem <svbergerem@online.de>
Date: Fri, 12 Dec 2014 00:00:08 +0100
Subject: [PATCH] Port contacts page to backbonejs

---
 .../app/collections/aspect_memberships.js     |   6 +
 .../javascripts/app/collections/contacts.js   |  21 ++
 .../app/helpers/handlebars-helpers.js         |  26 +-
 app/assets/javascripts/app/models/contact.js  |  13 +
 .../contacts_view.js => pages/contacts.js}    |  69 +----
 app/assets/javascripts/app/router.js          |  10 +-
 .../app/views/contact_stream_view.js          |  77 ++++++
 .../javascripts/app/views/contact_view.js     |  72 ++++++
 app/assets/stylesheets/contacts.css.scss      |   4 +-
 app/assets/templates/comment_tpl.jst.hbs      |   4 +-
 app/assets/templates/contact_tpl.jst.hbs      |  23 ++
 .../single-post-content_tpl.jst.hbs           |  24 +-
 .../single-post-interactions_tpl.jst.hbs      |   8 +-
 app/assets/templates/stream-faces_tpl.jst.hbs |   4 +-
 app/assets/templates/stream-frame_tpl.jst.hbs |   8 +-
 .../aspect_memberships_controller.rb          |   9 +-
 app/controllers/contacts_controller.rb        |  22 +-
 app/helpers/contacts_helper.rb                |  24 +-
 app/presenters/aspect_membership_presenter.rb |  11 +
 app/presenters/contact_presenter.rb           |  17 ++
 app/views/contacts/_contact.html.haml         |  12 -
 app/views/contacts/index.html.haml            |   5 +-
 config/locales/javascript/javascript.en.yml   |   1 +
 .../aspect_memberships_controller_spec.rb     |   4 +-
 .../jasmine_fixtures/contacts_spec.rb         |  10 +-
 .../collections/contacts_collection_spec.js   |  37 +++
 spec/javascripts/app/models/contact_spec.js   |  20 ++
 spec/javascripts/app/pages/contacts_spec.js   | 101 ++++++++
 .../app/views/contact_stream_view_spec.js     |  77 ++++++
 .../app/views/contact_view_spec.js            | 136 ++++++++++
 .../app/views/contacts_view_spec.js           | 240 ------------------
 .../aspect_membership_presenter_spec.rb       |  15 ++
 spec/presenters/contact_presenter_spec.rb     |  26 ++
 33 files changed, 746 insertions(+), 390 deletions(-)
 create mode 100644 app/assets/javascripts/app/collections/aspect_memberships.js
 create mode 100644 app/assets/javascripts/app/collections/contacts.js
 create mode 100644 app/assets/javascripts/app/models/contact.js
 rename app/assets/javascripts/app/{views/contacts_view.js => pages/contacts.js} (50%)
 create mode 100644 app/assets/javascripts/app/views/contact_stream_view.js
 create mode 100644 app/assets/javascripts/app/views/contact_view.js
 create mode 100644 app/assets/templates/contact_tpl.jst.hbs
 create mode 100644 app/presenters/aspect_membership_presenter.rb
 create mode 100644 app/presenters/contact_presenter.rb
 delete mode 100644 app/views/contacts/_contact.html.haml
 create mode 100644 spec/javascripts/app/collections/contacts_collection_spec.js
 create mode 100644 spec/javascripts/app/models/contact_spec.js
 create mode 100644 spec/javascripts/app/pages/contacts_spec.js
 create mode 100644 spec/javascripts/app/views/contact_stream_view_spec.js
 create mode 100644 spec/javascripts/app/views/contact_view_spec.js
 delete mode 100644 spec/javascripts/app/views/contacts_view_spec.js
 create mode 100644 spec/presenters/aspect_membership_presenter_spec.rb
 create mode 100644 spec/presenters/contact_presenter_spec.rb

diff --git a/app/assets/javascripts/app/collections/aspect_memberships.js b/app/assets/javascripts/app/collections/aspect_memberships.js
new file mode 100644
index 0000000000..dd8ee1b5d2
--- /dev/null
+++ b/app/assets/javascripts/app/collections/aspect_memberships.js
@@ -0,0 +1,6 @@
+// @license magnet:?xt=urn:btih:0b31508aeb0634b347b8270c7bee4d411b5d4109&dn=agpl-3.0.txt AGPL-v3-or-Later
+
+app.collections.AspectMemberships = Backbone.Collection.extend({
+  model: app.models.AspectMembership
+})
+// @license-end
diff --git a/app/assets/javascripts/app/collections/contacts.js b/app/assets/javascripts/app/collections/contacts.js
new file mode 100644
index 0000000000..d0592155f2
--- /dev/null
+++ b/app/assets/javascripts/app/collections/contacts.js
@@ -0,0 +1,21 @@
+// @license magnet:?xt=urn:btih:0b31508aeb0634b347b8270c7bee4d411b5d4109&dn=agpl-3.0.txt AGPL-v3-or-Later
+
+app.collections.Contacts = Backbone.Collection.extend({
+  model: app.models.Contact,
+
+  comparator : function(con1, con2) {
+    if( !con1.person || !con2.person ) return 1;
+
+    if(app.aspect) {
+      var inAspect1 = con1.inAspect(app.aspect.get('id'));
+      var inAspect2 = con2.inAspect(app.aspect.get('id'));
+      if(  inAspect1 && !inAspect2 ) return -1;
+      if( !inAspect1 &&  inAspect2 ) return 1;
+    }
+
+    var n1 = con1.person.get('name');
+    var n2 = con2.person.get('name');
+    return n1.localeCompare(n2);
+  }
+});
+// @license-end
diff --git a/app/assets/javascripts/app/helpers/handlebars-helpers.js b/app/assets/javascripts/app/helpers/handlebars-helpers.js
index 00c78ffaff..17f42e3c2a 100644
--- a/app/assets/javascripts/app/helpers/handlebars-helpers.js
+++ b/app/assets/javascripts/app/helpers/handlebars-helpers.js
@@ -21,7 +21,7 @@ Handlebars.registerHelper('urlTo', function(path_helper, id, data){
   return Routes[path_helper+'_path'](id, data.hash);
 });
 
-Handlebars.registerHelper('linkToPerson', function(context, block) {
+Handlebars.registerHelper('linkToAuthor', function(context, block) {
   if( !context ) context = this;
   var html = "<a href=\"/people/" + context.guid + "\" class=\"author-name ";
       html += Handlebars.helpers.hovercardable(context);
@@ -32,6 +32,15 @@ Handlebars.registerHelper('linkToPerson', function(context, block) {
   return html
 });
 
+Handlebars.registerHelper('linkToPerson', function(context, block) {
+  if( !context ) context = this;
+  var html = "<a href=\"/people/" + context.guid + "\" class=\"name\">";
+      html += block.fn(context);
+      html += "</a>";
+
+  return html
+});
+
 // relationship indicator for profile page
 Handlebars.registerHelper('sharingMessage', function(person) {
   var i18n_scope = 'people.helper.is_not_sharing';
@@ -107,5 +116,20 @@ Handlebars.registerHelper('isCurrentProfilePage', function(id, diaspora_handle,
   return Handlebars.helpers.isCurrentPage('person', id, options) ||
          Handlebars.helpers.isCurrentPage('user_profile', username, options);
 });
+
+Handlebars.registerHelper('aspectMembershipIndicator', function(contact,in_aspect) {
+  if(!app.aspect || !app.aspect.get('id')) return '<div class="aspect_membership_dropdown placeholder"></div>';
+
+  var html = '<i class="entypo ';
+  if( in_aspect == 'in_aspect' ) {
+    html += 'circled-cross contact_remove-from-aspect" ';
+    html += 'title="' + Diaspora.I18n.t('contacts.remove_contact') + '" ';
+  } else {
+    html += 'circled-plus contact_add-to-aspect" ';
+    html += 'title="' + Diaspora.I18n.t('contacts.add_contact') + '" ';
+  }
+  html += '></i>';
+  return html;
+});
 // @license-end
 
diff --git a/app/assets/javascripts/app/models/contact.js b/app/assets/javascripts/app/models/contact.js
new file mode 100644
index 0000000000..a1e014c4c1
--- /dev/null
+++ b/app/assets/javascripts/app/models/contact.js
@@ -0,0 +1,13 @@
+// @license magnet:?xt=urn:btih:0b31508aeb0634b347b8270c7bee4d411b5d4109&dn=agpl-3.0.txt AGPL-v3-or-Later
+
+app.models.Contact = Backbone.Model.extend({
+  initialize : function() {
+    this.aspect_memberships = new app.collections.AspectMemberships(this.get('aspect_memberships'));
+    if( this.get('person') ) this.person = new app.models.Person(this.get('person'));
+  },
+
+  inAspect : function(id) {
+    return this.aspect_memberships.any(function(membership){ return membership.get('aspect').id == id; });
+  }
+});
+// @license-end
diff --git a/app/assets/javascripts/app/views/contacts_view.js b/app/assets/javascripts/app/pages/contacts.js
similarity index 50%
rename from app/assets/javascripts/app/views/contacts_view.js
rename to app/assets/javascripts/app/pages/contacts.js
index 970719247e..ac44783944 100644
--- a/app/assets/javascripts/app/views/contacts_view.js
+++ b/app/assets/javascripts/app/pages/contacts.js
@@ -1,6 +1,6 @@
 // @license magnet:?xt=urn:btih:0b31508aeb0634b347b8270c7bee4d411b5d4109&dn=agpl-3.0.txt AGPL-v3-or-Later
 
-app.views.Contacts = Backbone.View.extend({
+app.pages.Contacts = Backbone.View.extend({
 
   el: "#contacts_container",
 
@@ -8,17 +8,15 @@ app.views.Contacts = Backbone.View.extend({
     "click #contacts_visibility_toggle" : "toggleContactVisibility",
     "click #chat_privilege_toggle" : "toggleChatPrivilege",
     "click #change_aspect_name" : "showAspectNameForm",
-    "click .contact_remove-from-aspect" : "removeContactFromAspect",
-    "click .contact_add-to-aspect" : "addContactToAspect",
     "keyup #contact_list_search" : "searchContactList"
   },
 
-  initialize: function() {
+  initialize: function(opts) {
     this.visibility_toggle = $("#contacts_visibility_toggle .entypo");
     this.chat_toggle = $("#chat_privilege_toggle .entypo");
+    this.stream = opts.stream;
+    this.stream.render();
     $("#people_stream.contacts .header .entypo").tooltip({ 'placement': 'bottom'});
-    $(".contact_remove-from-aspect").tooltip();
-    $(".contact_add-to-aspect").tooltip();
     $(document).on('ajax:success', 'form.edit_aspect', this.updateAspectName);
   },
 
@@ -69,65 +67,8 @@ app.views.Contacts = Backbone.View.extend({
     $(".header > h3").show();
   },
 
-  addContactToAspect: function(e){
-    var contact = $(e.currentTarget);
-    var aspect_membership = new app.models.AspectMembership({
-      'person_id': contact.attr('data-person_id'),
-      'aspect_id': contact.attr('data-aspect_id')
-    });
-
-    aspect_membership.save({},{
-      success: function(model,response){
-        contact.attr('data-membership_id',model.id)
-               .tooltip('destroy')
-               .removeAttr('data-original-title')
-               .removeClass("contact_add-to-aspect").removeClass("circled-plus")
-               .addClass("contact_remove-from-aspect").addClass("circled-cross")
-               .attr('title', Diaspora.I18n.t('contacts.remove_contact'))
-               .tooltip()
-               .closest('.stream_element').addClass('in_aspect');
-      },
-      error: function(model,response){
-        var msg = Diaspora.I18n.t('contacts.error_add', { 'name':contact.closest('.stream_element').find('.name').text() });
-        Diaspora.page.flashMessages.render({ 'success':false, 'notice':msg });
-      }
-    });
-  },
-
-  removeContactFromAspect: function(e){
-    var contact = $(e.currentTarget);
-    var aspect_membership = new app.models.AspectMembership({
-      'id': contact.attr('data-membership_id')
-    });
-
-    aspect_membership.destroy({
-      success: function(model,response){
-        contact.removeAttr('data-membership_id')
-               .tooltip('destroy')
-               .removeAttr('data-original-title')
-               .removeClass("contact_remove-from-aspect").removeClass("circled-cross")
-               .addClass("contact_add-to-aspect").addClass("circled-plus")
-               .attr('title', Diaspora.I18n.t('contacts.add_contact'))
-               .tooltip()
-               .closest('.stream_element').removeClass('in_aspect');
-      },
-      error: function(model,response){
-        var msg = Diaspora.I18n.t('contacts.error_remove', { 'name':contact.closest('.stream_element').find('.name').text() });
-        Diaspora.page.flashMessages.render({ 'success':false, 'notice':msg });
-      }
-    });
-  },
-
   searchContactList: function(e) {
-    var query = new RegExp($(e.target).val(),'i');
-
-    $("#people_stream.stream.contacts .stream_element").each(function(){
-      if($(this).find(".name").text().match(query)){
-        $(this).show();
-      } else {
-        $(this).hide();
-      }
-    });
+    this.stream.search($(e.target).val());
   }
 });
 // @license-end
diff --git a/app/assets/javascripts/app/router.js b/app/assets/javascripts/app/router.js
index c71222ff8b..76398707cd 100644
--- a/app/assets/javascripts/app/router.js
+++ b/app/assets/javascripts/app/router.js
@@ -43,7 +43,15 @@ app.Router = Backbone.Router.extend({
   },
 
   contacts: function() {
-    app.contacts = new app.views.Contacts();
+    app.aspect = new app.models.Aspect(gon.preloads.aspect);
+    app.contacts = new app.collections.Contacts(app.parsePreload('contacts'));
+
+    var stream = new app.views.ContactStream({
+      collection: app.contacts,
+      el: $('.stream.contacts #contact_stream'),
+    });
+
+    app.page = new app.pages.Contacts({stream: stream});
   },
 
   conversations: function() {
diff --git a/app/assets/javascripts/app/views/contact_stream_view.js b/app/assets/javascripts/app/views/contact_stream_view.js
new file mode 100644
index 0000000000..ad93301bd8
--- /dev/null
+++ b/app/assets/javascripts/app/views/contact_stream_view.js
@@ -0,0 +1,77 @@
+// @license magnet:?xt=urn:btih:0b31508aeb0634b347b8270c7bee4d411b5d4109&dn=agpl-3.0.txt AGPL-v3-or-Later
+
+app.views.ContactStream = Backbone.View.extend({
+  initialize: function() {
+    this.itemCount = 0;
+    this.perPage = 25;
+    this.query = '';
+    this.resultList = this.collection.toArray();
+    var throttledScroll = _.throttle(_.bind(this.infScroll, this), 200);
+    $(window).scroll(throttledScroll);
+    this.on('renderContacts', this.renderContacts, this);
+  },
+
+  render: function() {
+    if( _.isEmpty(this.resultList) ) {
+      var content = document.createDocumentFragment();
+      content = '<div id="no_contacts" class="well">' +
+                '  <h4>' +
+                     Diaspora.I18n.t('contacts.search_no_results') +
+                '  </h4>' +
+                '</div>';
+      this.$el.html(content);
+    } else {
+      this.$el.html('');
+      this.renderContacts();
+    }
+  },
+
+  renderContacts: function() {
+    this.$el.addClass("loading");
+    var content = document.createDocumentFragment();
+    _.rest(_.first(this.resultList , this.itemCount + this.perPage), this.itemCount).forEach( function(item) {
+      var view = new app.views.Contact({model: item});
+      content.appendChild(view.render().el);
+    });
+
+    var size = _.size(this.resultList);
+    if( this.itemCount + this.perPage >= size ){
+      this.itemCount = size;
+      this.off('renderContacts');
+    } else {
+      this.itemCount += this.perPage;
+    }
+    this.$el.append(content);
+    this.$el.removeClass("loading");
+  },
+
+  search: function(query) {
+    query = query.trim();
+    if( query || this.query ) {
+      this.off('renderContacts');
+      this.on('renderContacts', this.renderContacts, this);
+      this.itemCount = 0;
+      if( query ) {
+        this.query = query;
+        var regex = new RegExp(query,'i');
+        this.resultList = this.collection.filter(function(contact) {
+          return regex.test(contact.get('person').name) ||
+                 regex.test(contact.get('person').diaspora_id);
+        });
+      } else {
+        this.resultList = this.collection.toArray();
+        this.query = '';
+      }
+      this.render();
+    }
+  },
+
+  infScroll: function() {
+    if( this.$el.hasClass('loading') ) return;
+
+    var distanceTop = $(window).height() + $(window).scrollTop(),
+        distanceBottom = $(document).height() - distanceTop;
+    if(distanceBottom < 300) this.trigger('renderContacts');
+  }
+});
+// @license-end
diff --git a/app/assets/javascripts/app/views/contact_view.js b/app/assets/javascripts/app/views/contact_view.js
new file mode 100644
index 0000000000..4fd2d7efb5
--- /dev/null
+++ b/app/assets/javascripts/app/views/contact_view.js
@@ -0,0 +1,72 @@
+// @license magnet:?xt=urn:btih:0b31508aeb0634b347b8270c7bee4d411b5d4109&dn=agpl-3.0.txt AGPL-v3-or-Later
+
+app.views.Contact = app.views.Base.extend({
+  templateName: 'contact',
+
+  events: {
+    "click .contact_add-to-aspect" : "addContactToAspect",
+    "click .contact_remove-from-aspect" : "removeContactFromAspect"
+  },
+
+  tooltipSelector: '.contact_add-to-aspect, .contact_remove-from-aspect',
+
+  presenter: function() {
+    return _.extend(this.defaultPresenter(), {
+      person_id : this.model.get('person_id'),
+      person : this.model.get('person'),
+      in_aspect: (app.aspect && this.model.inAspect(app.aspect.get('id'))) ? 'in_aspect' : '',
+    });
+  },
+
+  postRenderTemplate: function() {
+    var self = this;
+    var dropdownEl = this.$('.aspect_membership_dropdown.placeholder');
+    if( dropdownEl.length == 0 ) {
+      return;
+    }
+
+    // TODO render me client side!!!
+    var href = this.model.person.url() + '/aspect_membership_button?size=small';
+    if( gon.bootstrap ) href += '&bootstrap=true';
+
+    $.get(href, function(resp) {
+      dropdownEl.html(resp);
+      new app.views.AspectMembership({el: $('.aspect_dropdown',dropdownEl)});
+
+      // UGLY (re-)attach the facebox
+      self.$('a[rel*=facebox]').facebox();
+    });
+  },
+
+  addContactToAspect: function(){
+    var self = this;
+    this.model.aspect_memberships.create({
+      'person_id': this.model.get('person_id'),
+      'aspect_id': app.aspect.get('id')
+    },{
+      success: function(model,response){
+        self.render();
+      },
+      error: function(model,response){
+        var msg = Diaspora.I18n.t('contacts.error_add', { 'name': self.model.get('person').name });
+        Diaspora.page.flashMessages.render({ 'success':false, 'notice':msg });
+      }
+    });
+  },
+
+  removeContactFromAspect: function(){
+    var self = this;
+    this.model.aspect_memberships
+      .find(function(membership){ return membership.get('aspect').id == app.aspect.id; })
+      .destroy({
+        success: function(model,response){
+          self.render();
+        },
+        error: function(model,response){
+          var msg = Diaspora.I18n.t('contacts.error_remove', { 'name': self.model.get('person').name });
+          Diaspora.page.flashMessages.render({ 'success':false, 'notice':msg });
+        }
+      });
+  }
+});
+// @license-end
diff --git a/app/assets/stylesheets/contacts.css.scss b/app/assets/stylesheets/contacts.css.scss
index 5bf2597a63..b2eae53054 100644
--- a/app/assets/stylesheets/contacts.css.scss
+++ b/app/assets/stylesheets/contacts.css.scss
@@ -66,8 +66,6 @@
 }
 
 #no_contacts {
+  margin-top: 20px;
   text-align: center;
-  padding: 10px;
-  background-color: #eee;
-  color: $text-dark-grey;
 }
diff --git a/app/assets/templates/comment_tpl.jst.hbs b/app/assets/templates/comment_tpl.jst.hbs
index 23eb368d9f..3196cad332 100644
--- a/app/assets/templates/comment_tpl.jst.hbs
+++ b/app/assets/templates/comment_tpl.jst.hbs
@@ -1,8 +1,8 @@
 <div id="{{guid}}">
   <div class="img">
-    {{#linkToPerson author}}
+    {{#linkToAuthor author}}
       {{{personImage this "small" "small"}}}
-    {{/linkToPerson}}
+    {{/linkToAuthor}}
   </div>
 
   <div class="bd">
diff --git a/app/assets/templates/contact_tpl.jst.hbs b/app/assets/templates/contact_tpl.jst.hbs
new file mode 100644
index 0000000000..a89542f4ef
--- /dev/null
+++ b/app/assets/templates/contact_tpl.jst.hbs
@@ -0,0 +1,23 @@
+<div class="stream_element media contact {{in_aspect}}" id={{person_id}}>
+  <div class="pull-right">
+    {{{aspectMembershipIndicator this in_aspect}}}
+  </div>
+
+  <div class="media-object pull-left">
+    {{{personImage person 'small'}}}
+  </div>
+
+  <div class="media-body">
+    {{#linkToPerson person}}
+      {{name}}
+    {{/linkToPerson}}
+
+    <div class="info diaspora_handle">
+      {{person.diaspora_id}}
+    </div>
+
+    <div class="info tags">
+      {{{fmtTags person.profile.tags}}}
+    </div>
+  </div>
+</div>
diff --git a/app/assets/templates/single-post-viewer/single-post-content_tpl.jst.hbs b/app/assets/templates/single-post-viewer/single-post-content_tpl.jst.hbs
index a144e03902..e804b3c956 100644
--- a/app/assets/templates/single-post-viewer/single-post-content_tpl.jst.hbs
+++ b/app/assets/templates/single-post-viewer/single-post-content_tpl.jst.hbs
@@ -3,26 +3,26 @@
     <div id='post-info' class='span8'>
       <div class="img pull-left">
         {{#if root}}
-          {{#linkToPerson root.author}}
+          {{#linkToAuthor root.author}}
             {{{personImage this 'medium'}}}
-          {{/linkToPerson}}
+          {{/linkToAuthor}}
         {{else}}
-          {{#linkToPerson author}}
+          {{#linkToAuthor author}}
             {{{personImage this 'medium'}}}
-          {{/linkToPerson}}
+          {{/linkToAuthor}}
         {{/if}}
       </div>
 
       <div class="bd">
         <span class='author'>
           {{#if root}}
-            {{#linkToPerson root.author}}
+            {{#linkToAuthor root.author}}
               {{name}}
-            {{/linkToPerson}}
+            {{/linkToAuthor}}
           {{else}}
-            {{#linkToPerson author}}
+            {{#linkToAuthor author}}
               {{name}}
-            {{/linkToPerson}}
+            {{/linkToAuthor}}
           {{/if}}
         </span>
             
@@ -69,14 +69,14 @@
       <div class='span8' id='reshare-info'>
         <i class='entypo retweet small pull-left'></i>
         <div class="img pull-left">
-          {{#linkToPerson author}}
+          {{#linkToAuthor author}}
             {{{personImage this 'small'}}}
-          {{/linkToPerson}}
+          {{/linkToAuthor}}
         </div>
         <span class="author">
-          {{#linkToPerson author}}
+          {{#linkToAuthor author}}
             {{name}}
-          {{/linkToPerson}}
+          {{/linkToAuthor}}
         </span>
         <span class="post-time">
           <a href="/posts/{{id}}">
diff --git a/app/assets/templates/single-post-viewer/single-post-interactions_tpl.jst.hbs b/app/assets/templates/single-post-viewer/single-post-interactions_tpl.jst.hbs
index aebe7a6801..2488ea0de6 100644
--- a/app/assets/templates/single-post-viewer/single-post-interactions_tpl.jst.hbs
+++ b/app/assets/templates/single-post-viewer/single-post-interactions_tpl.jst.hbs
@@ -6,9 +6,9 @@
     </span>
     <span>
       {{#each reshares}}
-        {{#linkToPerson author}}
+        {{#linkToAuthor author}}
           {{{personImage this 'small' 'micro'}}}
-        {{/linkToPerson}}
+        {{/linkToAuthor}}
       {{/each}}
     </span>
   </div>
@@ -21,9 +21,9 @@
     </span>
     <span>
       {{#each likes}}
-        {{#linkToPerson author}}
+        {{#linkToAuthor author}}
           {{{personImage this 'small' 'micro'}}}
-        {{/linkToPerson}}
+        {{/linkToAuthor}}
       {{/each}}
     </span>
   </div>
diff --git a/app/assets/templates/stream-faces_tpl.jst.hbs b/app/assets/templates/stream-faces_tpl.jst.hbs
index 6db001f192..2e9898905c 100644
--- a/app/assets/templates/stream-faces_tpl.jst.hbs
+++ b/app/assets/templates/stream-faces_tpl.jst.hbs
@@ -1,5 +1,5 @@
 {{#people}}
-  {{#linkToPerson this}}
+  {{#linkToAuthor this}}
     {{{personImage this "small"}}}
-  {{/linkToPerson}}
+  {{/linkToAuthor}}
 {{/people}}
diff --git a/app/assets/templates/stream-frame_tpl.jst.hbs b/app/assets/templates/stream-frame_tpl.jst.hbs
index 5574a479de..a9f55163c1 100644
--- a/app/assets/templates/stream-frame_tpl.jst.hbs
+++ b/app/assets/templates/stream-frame_tpl.jst.hbs
@@ -14,16 +14,16 @@
     <div class='stream-frame-feedback'></div>
 
     <div class="media author">
-        {{#linkToPerson author}}
+        {{#linkToAuthor author}}
             <div class="img">
                 <div class="profile-image-container smaller" style="background-image : url('{{avatar.large}}')"></div>
             </div>
-        {{/linkToPerson}}
+        {{/linkToAuthor}}
 
         <div class="bd">
-            {{#linkToPerson author}}
+            {{#linkToAuthor author}}
                 {{name}}
-            {{/linkToPerson}}
+            {{/linkToAuthor}}
         </div>
     </div>
 
diff --git a/app/controllers/aspect_memberships_controller.rb b/app/controllers/aspect_memberships_controller.rb
index 5009902b92..eddfa39359 100644
--- a/app/controllers/aspect_memberships_controller.rb
+++ b/app/controllers/aspect_memberships_controller.rb
@@ -34,10 +34,7 @@ class AspectMembershipsController < ApplicationController
     respond_to do |format|
       format.json do
         if success
-          render :json => {
-            :person_id  => contact.person_id,
-            :aspect_ids => contact.aspects.map{|a| a.id}
-          }
+          render :json => AspectMembershipPresenter.new(membership).base_hash
         else
           render :text => membership.errors.full_messages, :status => 403
         end
@@ -57,7 +54,9 @@ class AspectMembershipsController < ApplicationController
       flash.now[:notice] =  I18n.t('aspects.add_to_aspect.success')
       respond_with do |format|
         format.json do
-          render :json => AspectMembership.where(:contact_id => @contact.id, :aspect_id => @aspect.id).first.to_json
+          render :json => AspectMembershipPresenter.new(
+            AspectMembership.where(:contact_id => @contact.id, :aspect_id => @aspect.id).first)
+          .base_hash
         end
 
         format.all { redirect_to :back }
diff --git a/app/controllers/contacts_controller.rb b/app/controllers/contacts_controller.rb
index 9a57ba3e43..0e6168c9c6 100644
--- a/app/controllers/contacts_controller.rb
+++ b/app/controllers/contacts_controller.rb
@@ -40,32 +40,24 @@ class ContactsController < ApplicationController
 
     @contacts = contacts_by_type(type)
     @contacts_size = @contacts.length
+    gon.preloads[:contacts] = @contacts.map{ |c| ContactPresenter.new(c, current_user).full_hash_with_person }
   end
 
   def contacts_by_type(type)
-    contacts = case type
+    case type
       when "all"
-        [current_user.contacts]
+        current_user.contacts
       when "only_sharing"
-        [current_user.contacts.only_sharing]
+        current_user.contacts.only_sharing
       when "receiving"
-        [current_user.contacts.receiving]
+        current_user.contacts.receiving
       when "by_aspect"
         @aspect = current_user.aspects.find(params[:a_id])
-        @contacts_in_aspect = @aspect.contacts
-        @contacts_not_in_aspect = current_user.contacts.where.not(contacts: {id: @contacts_in_aspect.pluck(:id) })
-        [@contacts_in_aspect, @contacts_not_in_aspect].map {|relation|
-          relation.includes(:aspect_memberships)
-        }
+        gon.preloads[:aspect] = AspectPresenter.new(@aspect).as_json
+        current_user.contacts
       else
         raise ArgumentError, "unknown type #{type}"
       end
-
-    contacts.map {|relation|
-      relation.includes(:person => :profile).to_a.tap {|contacts|
-        contacts.sort_by! {|contact| contact.person.name }
-      }
-    }.inject(:+).paginate(:page => params[:page], :per_page => 25)
   end
 
   def set_up_contacts_mobile
diff --git a/app/helpers/contacts_helper.rb b/app/helpers/contacts_helper.rb
index 5ea96b5a26..3fdee52f35 100644
--- a/app/helpers/contacts_helper.rb
+++ b/app/helpers/contacts_helper.rb
@@ -1,25 +1,9 @@
 module ContactsHelper
   def contact_aspect_dropdown(contact)
-    membership = contact.aspect_memberships.where(:aspect_id => @aspect.id).first unless @aspect.nil?
-
-    if membership
-      content_tag(:i, nil, :class => 'entypo circled-cross contact_remove-from-aspect',
-                  :title => t('contacts.index.remove_contact'),
-                  'data-aspect_id' => @aspect.id,
-                  'data-person_id' => contact.person_id,
-                  'data-membership_id' => membership.id )
-
-    elsif @aspect.nil?
-      render :partial => 'people/relationship_action',
-              :locals => { :person => contact.person,
-                           :contact => contact,
-                           :current_user => current_user }
-    else
-      content_tag(:i, nil, :class => 'entypo circled-plus contact_add-to-aspect',
-                  :title => t('contacts.index.add_contact'),
-                  'data-aspect_id' => @aspect.id,
-                  'data-person_id' => contact.person_id )
-    end
+    render :partial => 'people/relationship_action',
+            :locals => { :person => contact.person,
+                         :contact => contact,
+                         :current_user => current_user }
   end
 
   def start_a_conversation_link(aspect, contacts_size)
diff --git a/app/presenters/aspect_membership_presenter.rb b/app/presenters/aspect_membership_presenter.rb
new file mode 100644
index 0000000000..28a96e38f2
--- /dev/null
+++ b/app/presenters/aspect_membership_presenter.rb
@@ -0,0 +1,11 @@
+class AspectMembershipPresenter < BasePresenter
+  def initialize(membership)
+    @membership = membership
+  end
+
+  def base_hash
+    { id: @membership.id,
+      aspect: AspectPresenter.new(@membership.aspect).as_json,
+    }
+  end
+end
diff --git a/app/presenters/contact_presenter.rb b/app/presenters/contact_presenter.rb
new file mode 100644
index 0000000000..9ab8b2d267
--- /dev/null
+++ b/app/presenters/contact_presenter.rb
@@ -0,0 +1,17 @@
+class ContactPresenter < BasePresenter
+  def base_hash
+    { id: id,
+      person_id: person_id
+    }
+  end
+
+  def full_hash
+    base_hash.merge({
+      aspect_memberships: aspect_memberships.map{ |membership| AspectMembershipPresenter.new(membership).base_hash }
+    })
+  end
+
+  def full_hash_with_person
+    full_hash.merge({person: PersonPresenter.new(person).full_hash_with_profile})
+  end
+end
diff --git a/app/views/contacts/_contact.html.haml b/app/views/contacts/_contact.html.haml
deleted file mode 100644
index 3e6aeb8a40..0000000000
--- a/app/views/contacts/_contact.html.haml
+++ /dev/null
@@ -1,12 +0,0 @@
-- membership = contact.aspect_memberships.where(:aspect_id => @aspect.id).first unless @aspect.nil?
-.media.stream_element{:id => contact.person_id, :class => ("in_aspect" if membership)}
-  .pull-right
-    = contact_aspect_dropdown(contact)
-  .media-object.pull-left
-    = person_image_link(contact.person, :size => :thumb_small)
-  .media-body
-    = person_link(contact.person, :class => 'name')
-    .info.diaspora_handle
-      = contact.person_diaspora_handle
-    .info.tags
-      = Diaspora::Taggable.format_tags(contact.person.profile.tag_string)
diff --git a/app/views/contacts/index.html.haml b/app/views/contacts/index.html.haml
index 4d8f223b4f..62161cbfad 100644
--- a/app/views/contacts/index.html.haml
+++ b/app/views/contacts/index.html.haml
@@ -11,8 +11,9 @@
         = render 'contacts/header'
 
         - if @contacts_size > 0
-          = render @contacts
-          = will_paginate @contacts
+          #contact_stream
+            -# JS
+
         - else
           .no_contacts
             %h3
diff --git a/config/locales/javascript/javascript.en.yml b/config/locales/javascript/javascript.en.yml
index 78fdc4533b..77fbeb3d14 100644
--- a/config/locales/javascript/javascript.en.yml
+++ b/config/locales/javascript/javascript.en.yml
@@ -48,6 +48,7 @@ en:
       remove_contact: "Remove contact"
       error_add: "Couldn't add <%= name %> to the aspect :("
       error_remove: "Couldn't remove <%= name %> from the aspect :("
+      search_no_results: "No contacts found"
 
     my_activity: "My Activity"
     my_stream: "Stream"
diff --git a/spec/controllers/aspect_memberships_controller_spec.rb b/spec/controllers/aspect_memberships_controller_spec.rb
index 0a5e125e90..abdae8de8a 100644
--- a/spec/controllers/aspect_memberships_controller_spec.rb
+++ b/spec/controllers/aspect_memberships_controller_spec.rb
@@ -72,14 +72,14 @@ describe AspectMembershipsController, :type => :controller do
     end
 
     context 'json' do
-      it 'returns a list of aspect ids for the person' do
+      it 'returns the aspect membership' do
         post :create,
         :format => :json,
         :person_id => @person.id,
         :aspect_id => @aspect0.id
 
         contact = @controller.current_user.contact_for(@person)
-        expect(response.body).to eq(contact.aspect_memberships.first.to_json)
+        expect(response.body).to eq(AspectMembershipPresenter.new(contact.aspect_memberships.first).base_hash.to_json)
       end
     end
   end
diff --git a/spec/controllers/jasmine_fixtures/contacts_spec.rb b/spec/controllers/jasmine_fixtures/contacts_spec.rb
index f21500def4..6311c3b343 100644
--- a/spec/controllers/jasmine_fixtures/contacts_spec.rb
+++ b/spec/controllers/jasmine_fixtures/contacts_spec.rb
@@ -10,12 +10,20 @@ describe ContactsController, :type => :controller do
       AppConfig.chat.enabled = true
       @aspect = bob.aspects.create(:name => "another aspect")
       bob.share_with alice.person, @aspect
+      bob.share_with eve.person, @aspect
       sign_in :user, bob
     end
 
-    it "generates a jasmine fixture", :fixture => true do
+    it "generates the aspects_manage fixture", :fixture => true do
       get :index, :a_id => @aspect.id
       save_fixture(html_for("body"), "aspects_manage")
     end
+
+    it "generates the contacts_json fixture", :fixture => true do
+      json = bob.contacts.map { |c|
+               ContactPresenter.new(c, bob).full_hash_with_person
+             }.to_json
+      save_fixture(json, "contacts_json")
+    end
   end
 end
diff --git a/spec/javascripts/app/collections/contacts_collection_spec.js b/spec/javascripts/app/collections/contacts_collection_spec.js
new file mode 100644
index 0000000000..3b64d4bd97
--- /dev/null
+++ b/spec/javascripts/app/collections/contacts_collection_spec.js
@@ -0,0 +1,37 @@
+describe("app.collections.Contacts", function(){
+  beforeEach(function(){
+    this.collection = new app.collections.Contacts();
+  });
+
+  describe("comparator", function() {
+    beforeEach(function(){
+      this.aspect = new app.models.Aspect({id: 42, name: "cats"});
+      this.con1 = new app.models.Contact({
+                    person: { name: "aaa" },
+                    aspect_memberships: []
+                  });
+      this.con2 = new app.models.Contact({
+                    person: { name: "aaa" },
+                    aspect_memberships: [{id: 23, aspect: this.aspect}]
+                  });
+      this.con3 = new app.models.Contact({
+                    person: { name: "zzz" },
+                    aspect_memberships: [{id: 23, aspect: this.aspect}]
+                  });
+    });
+
+    it("should compare the username if app.aspect is not present", function() {
+      expect(this.collection.comparator(this.con1, this.con3)).toBeLessThan(0);
+    });
+
+    it("should compare the aspect memberships if app.aspect is present", function() {
+      app.aspect = this.aspect;
+      expect(this.collection.comparator(this.con1, this.con3)).toBeGreaterThan(0);
+    });
+
+    it("should compare the username if the contacts have equal aspect memberships", function() {
+      app.aspect = this.aspect;
+      expect(this.collection.comparator(this.con2, this.con3)).toBeLessThan(0);
+    });
+  });
+});
diff --git a/spec/javascripts/app/models/contact_spec.js b/spec/javascripts/app/models/contact_spec.js
new file mode 100644
index 0000000000..265039792c
--- /dev/null
+++ b/spec/javascripts/app/models/contact_spec.js
@@ -0,0 +1,20 @@
+describe("app.models.Contact", function() {
+
+  beforeEach(function(){
+    this.aspect = factory.aspect();
+    this.contact = new app.models.Contact({
+                     person: { name: "aaa" },
+                     aspect_memberships: [{id: 42, aspect: this.aspect}]
+                   });
+  });
+
+  describe("inAspect", function(){
+    it("returns true if the contact has been added to the aspect", function(){
+      expect(this.contact.inAspect(this.aspect.id)).toBeTruethy;
+    });
+
+    it("returns false if the contact hasn't been added to the aspect", function(){
+      expect(this.contact.inAspect(this.aspect.id+1)).toBeFalsy;
+    });
+  });
+});
diff --git a/spec/javascripts/app/pages/contacts_spec.js b/spec/javascripts/app/pages/contacts_spec.js
new file mode 100644
index 0000000000..6aa1975bf2
--- /dev/null
+++ b/spec/javascripts/app/pages/contacts_spec.js
@@ -0,0 +1,101 @@
+describe("app.pages.Contacts", function(){
+  beforeEach(function() {
+    spec.loadFixture("aspects_manage");
+    this.view = new app.pages.Contacts({
+      stream: {
+        render: function(){}
+      }
+    });
+    Diaspora.I18n.load({
+      contacts: {
+        aspect_list_is_visible: "Contacts in this aspect are able to see each other.",
+        aspect_list_is_not_visible: "Contacts in this aspect are not able to see each other.",
+        aspect_chat_is_enabled: "Contacts in this aspect are able to chat with you.",
+        aspect_chat_is_not_enabled: "Contacts in this aspect are not able to chat with you.",
+      }
+    });
+  });
+
+  context('toggle chat privilege', function() {
+    beforeEach(function() {
+      this.chat_toggle = $("#chat_privilege_toggle");
+      this.chat_icon = $("#chat_privilege_toggle .entypo");
+    });
+
+    it('updates the title for the tooltip', function() {
+      expect(this.chat_icon.attr('data-original-title')).toBe(
+        Diaspora.I18n.t("contacts.aspect_chat_is_not_enabled")
+      );
+      this.chat_toggle.trigger('click');
+      expect(this.chat_icon.attr('data-original-title')).toBe(
+        Diaspora.I18n.t("contacts.aspect_chat_is_enabled")
+      );
+    });
+
+    it('toggles the chat icon', function() {
+      expect(this.chat_icon.hasClass('enabled')).toBeFalsy;
+      this.chat_toggle.trigger('click');
+      expect(this.chat_icon.hasClass('enabled')).toBeTruethy;
+    });
+  });
+
+  context('toggle contacts visibility', function() {
+    beforeEach(function() {
+      this.visibility_toggle = $("#contacts_visibility_toggle");
+      this.lock_icon = $("#contacts_visibility_toggle .entypo");
+    });
+
+    it('updates the title for the tooltip', function() {
+      expect(this.lock_icon.attr('data-original-title')).toBe(
+        Diaspora.I18n.t("contacts.aspect_list_is_visible")
+      );
+
+      this.visibility_toggle.trigger('click');
+
+      expect(this.lock_icon.attr('data-original-title')).toBe(
+        Diaspora.I18n.t("contacts.aspect_list_is_not_visible")
+      );
+    });
+
+    it('toggles the lock icon', function() {
+      expect(this.lock_icon.hasClass('lock-open')).toBeTruethy;
+      expect(this.lock_icon.hasClass('lock')).toBeFalsy;
+
+      this.visibility_toggle.trigger('click');
+
+      expect(this.lock_icon.hasClass('lock')).toBeTruethy;
+      expect(this.lock_icon.hasClass('lock-open')).toBeFalsy;
+    });
+  });
+
+  context('show aspect name form', function() {
+    beforeEach(function() {
+      this.button = $('#change_aspect_name');
+    });
+
+    it('shows the form', function() {
+      expect($('#aspect_name_form').css('display')).toBe('none');
+      this.button.trigger('click');
+      expect($('#aspect_name_form').css('display')).not.toBe('none');
+    });
+
+    it('hides the aspect name', function() {
+      expect($('.header > h3').css('display')).not.toBe('none');
+      this.button.trigger('click');
+      expect($('.header > h3').css('display')).toBe('none');
+    });
+  });
+
+  context('search contact list', function() {
+    beforeEach(function() {
+      this.searchinput = $('#contact_list_search');
+    });
+
+    it('calls stream.search', function() {
+      this.view.stream.search = jasmine.createSpy();
+      this.searchinput.val("Username");
+      this.searchinput.trigger('keyup');
+      expect(this.view.stream.search).toHaveBeenCalledWith("Username");
+    });
+  });
+});
diff --git a/spec/javascripts/app/views/contact_stream_view_spec.js b/spec/javascripts/app/views/contact_stream_view_spec.js
new file mode 100644
index 0000000000..955dd2e7b1
--- /dev/null
+++ b/spec/javascripts/app/views/contact_stream_view_spec.js
@@ -0,0 +1,77 @@
+describe("app.views.ContactStream", function() {
+  beforeEach(function() {
+    loginAs({name: "alice", avatar : {small : "http://avatar.com/photo.jpg"}});
+    spec.loadFixture("aspects_manage");
+    this.contacts = new app.collections.Contacts($.parseJSON(spec.readFixture("contacts_json")));
+    app.aspect = new app.models.Aspect(this.contacts.first().get('aspect_memberships')[0].aspect);
+    this.view = new app.views.ContactStream({
+      collection : this.contacts,
+      el: $('.stream.contacts #contact_stream')
+    });
+
+    this.view.perPage=1;
+
+    //clean the page
+    this.view.$el.html('');
+  });
+
+  describe("initialize", function() {
+    it("binds an infinite scroll listener", function() {
+      spyOn($.fn, "scroll");
+      new app.views.ContactStream({collection : this.contacts});
+      expect($.fn.scroll).toHaveBeenCalled();
+    });
+  });
+
+  describe("search", function() {
+    it("filters the contacts", function() {
+      this.view.render();
+      expect(this.view.$el.html()).toContain("alice");
+      this.view.search("eve");
+      expect(this.view.$el.html()).not.toContain("alice");
+      expect(this.view.$el.html()).toContain("eve");
+    });
+  });
+
+  describe("infScroll", function() {
+    beforeEach(function() {
+      this.view.off("renderContacts");
+      this.fn = jasmine.createSpy();
+      this.view.on("renderContacts", this.fn);
+      spyOn($.fn, "height").and.returnValue(0);
+      spyOn($.fn, "scrollTop").and.returnValue(100);
+    });
+
+    it("triggers renderContacts when the user is at the bottom of the page", function() {
+      this.view.infScroll();
+      expect(this.fn).toHaveBeenCalled();
+    });
+  });
+
+  describe("render", function() {
+    beforeEach(function() {
+      spyOn(this.view, "renderContacts");
+    });
+
+    it("calls renderContacts", function() {
+      this.view.render();
+      expect(this.view.renderContacts).toHaveBeenCalled();
+    });
+  });
+
+  describe("renderContacts", function() {
+    beforeEach(function() {
+      this.view.off("renderContacts");
+      this.view.renderContacts();
+    });
+
+    it("renders perPage contacts", function() {
+      expect(this.view.$el.find('.stream_element.contact').length).toBe(1);
+    });
+
+    it("renders more contacts when called a second time", function() {
+      this.view.renderContacts();
+      expect(this.view.$el.find('.stream_element.contact').length).toBe(2);
+    });
+  });
+});
diff --git a/spec/javascripts/app/views/contact_view_spec.js b/spec/javascripts/app/views/contact_view_spec.js
new file mode 100644
index 0000000000..0dee502e93
--- /dev/null
+++ b/spec/javascripts/app/views/contact_view_spec.js
@@ -0,0 +1,136 @@
+describe("app.views.Contact", function(){
+  beforeEach(function() {
+    this.aspect1 = factory.aspect({id: 1});
+    this.aspect2 = factory.aspect({id: 2});
+
+    this.model = new app.models.Contact({
+      person_id: 42,
+      person: { id: 42, name: 'alice' },
+      aspect_memberships: [{id: 23, aspect: this.aspect1}]
+    });
+    this.view = new app.views.Contact({ model: this.model });
+    Diaspora.I18n.load({
+      contacts: {
+        add_contact: "Add contact",
+        remove_contact: "Remove contact",
+        error_add: "Couldn't add <%= name %> to the aspect :(",
+        error_remove: "Couldn't remove <%= name %> from the aspect :("
+      }
+    });
+  });
+
+  context("#presenter", function() {
+    it("contains necessary elements", function() {
+      app.aspect = this.aspect1;
+      expect(this.view.presenter()).toEqual(jasmine.objectContaining({
+        person_id: 42,
+        person: jasmine.objectContaining({id: 42, name: 'alice'}),
+        in_aspect: 'in_aspect'
+      }));
+    });
+  });
+
+  context('add contact to aspect', function() {
+    beforeEach(function() {
+      app.aspect = this.aspect2;
+      this.view.render();
+      this.button = this.view.$el.find('.contact_add-to-aspect');
+      this.contact = this.view.$el.find('.stream_element.contact');
+      this.aspect_membership = {id: 42, aspect: app.aspect.toJSON()};
+      this.response = JSON.stringify(this.aspect_membership);
+    });
+
+    it('sends a correct ajax request', function() {
+      this.button.trigger('click');
+      var obj = $.parseJSON(jasmine.Ajax.requests.mostRecent().params);
+      expect(obj.person_id).toBe(this.model.get('person_id'));
+      expect(obj.aspect_id).toBe(app.aspect.get('id'));
+    });
+
+    it('adds a aspect_membership to the contact', function() {
+      expect(this.model.aspect_memberships.length).toBe(1);
+      $('.contact_add-to-aspect',this.contact).trigger('click');
+      jasmine.Ajax.requests.mostRecent().response({
+        status: 200, // success
+        responseText: this.response
+      });
+      expect(this.model.aspect_memberships.length).toBe(2);
+    });
+
+    it('calls render', function() {
+      spyOn(this.view, 'render');
+      $('.contact_add-to-aspect',this.contact).trigger('click');
+      jasmine.Ajax.requests.mostRecent().response({
+        status: 200, // success
+        responseText: this.response
+      });
+      expect(this.view.render).toHaveBeenCalled();
+    });
+
+
+    it('displays a flash message on errors', function(){
+      $('.contact_add-to-aspect',this.contact).trigger('click');
+      jasmine.Ajax.requests.mostRecent().response({
+        status: 400, // fail
+      });
+      expect($('[id^="flash"]')).toBeErrorFlashMessage(
+        Diaspora.I18n.t(
+          'contacts.error_add',
+          {name: this.model.get('person').name}
+        )
+      );
+    });
+  });
+
+  context('remove contact from aspect', function() {
+    beforeEach(function() {
+      app.aspect = this.aspect1;
+      this.view.render();
+      this.button = this.view.$el.find('.contact_remove-from-aspect');
+      this.contact = this.view.$el.find('.stream_element.contact');
+      this.aspect_membership = this.model.aspect_memberships.first().toJSON();
+      this.response = JSON.stringify(this.aspect_membership);
+    });
+
+    it('sends a correct ajax request', function() {
+      $('.contact_remove-from-aspect',this.contact).trigger('click');
+      expect(jasmine.Ajax.requests.mostRecent().url).toBe(
+        "/aspect_memberships/"+this.aspect_membership.id
+      );
+    });
+
+    it('removes the aspect_membership from the contact', function() {
+      expect(this.model.aspect_memberships.length).toBe(1);
+      $('.contact_remove-from-aspect',this.contact).trigger('click');
+      jasmine.Ajax.requests.mostRecent().response({
+        status: 200, // success
+        responseText: this.response
+      });
+      expect(this.model.aspect_memberships.length).toBe(0);
+    });
+
+    it('calls render', function() {
+      spyOn(this.view, 'render');
+      $('.contact_remove-from-aspect',this.contact).trigger('click');
+      jasmine.Ajax.requests.mostRecent().response({
+        status: 200, // success
+        responseText: this.response,
+      });
+      expect(this.view.render).toHaveBeenCalled();
+    });
+
+    it('displays a flash message on errors', function(){
+      $('.contact_remove-from-aspect',this.contact).trigger('click');
+      jasmine.Ajax.requests.mostRecent().response({
+        status: 400, // fail
+      });
+      expect($('[id^="flash"]')).toBeErrorFlashMessage(
+        Diaspora.I18n.t(
+          'contacts.error_remove',
+          {name: this.model.get('person').name}
+        )
+      );
+    });
+  });
+
+});
diff --git a/spec/javascripts/app/views/contacts_view_spec.js b/spec/javascripts/app/views/contacts_view_spec.js
deleted file mode 100644
index b3a9f736cb..0000000000
--- a/spec/javascripts/app/views/contacts_view_spec.js
+++ /dev/null
@@ -1,240 +0,0 @@
-describe("app.views.Contacts", function(){
-  beforeEach(function() {
-    spec.loadFixture("aspects_manage");
-    this.view = new app.views.Contacts();
-    Diaspora.I18n.load({
-      contacts: {
-        add_contact: "Add contact",
-        aspect_list_is_visible: "Contacts in this aspect are able to see each other.",
-        aspect_list_is_not_visible: "Contacts in this aspect are not able to see each other.",
-        aspect_chat_is_enabled: "Contacts in this aspect are able to chat with you.",
-        aspect_chat_is_not_enabled: "Contacts in this aspect are not able to chat with you.",
-        remove_contact: "Remove contact",
-        error_add: "Couldn't add <%= name %> to the aspect :(",
-        error_remove: "Couldn't remove <%= name %> from the aspect :("
-      }
-    });
-  });
-
-  context('toggle chat privilege', function() {
-    beforeEach(function() {
-      this.chat_toggle = $("#chat_privilege_toggle");
-      this.chat_icon = $("#chat_privilege_toggle .entypo");
-    });
-
-    it('updates the title for the tooltip', function() {
-      expect(this.chat_icon.attr('data-original-title')).toBe(
-        Diaspora.I18n.t("contacts.aspect_chat_is_not_enabled")
-      );
-      this.chat_toggle.trigger('click');
-      expect(this.chat_icon.attr('data-original-title')).toBe(
-        Diaspora.I18n.t("contacts.aspect_chat_is_enabled")
-      );
-    });
-
-    it('toggles the chat icon', function() {
-      expect(this.chat_icon.hasClass('enabled')).toBeFalsy;
-      this.chat_toggle.trigger('click');
-      expect(this.chat_icon.hasClass('enabled')).toBeTruethy;
-    });
-  });
-
-  context('toggle contacts visibility', function() {
-    beforeEach(function() {
-      this.visibility_toggle = $("#contacts_visibility_toggle");
-      this.lock_icon = $("#contacts_visibility_toggle .entypo");
-    });
-
-    it('updates the title for the tooltip', function() {
-      expect(this.lock_icon.attr('data-original-title')).toBe(
-        Diaspora.I18n.t("contacts.aspect_list_is_visible")
-      );
-
-      this.visibility_toggle.trigger('click');
-
-      expect(this.lock_icon.attr('data-original-title')).toBe(
-        Diaspora.I18n.t("contacts.aspect_list_is_not_visible")
-      );
-    });
-
-    it('toggles the lock icon', function() {
-      expect(this.lock_icon.hasClass('lock-open')).toBeTruethy;
-      expect(this.lock_icon.hasClass('lock')).toBeFalsy;
-
-      this.visibility_toggle.trigger('click');
-
-      expect(this.lock_icon.hasClass('lock')).toBeTruethy;
-      expect(this.lock_icon.hasClass('lock-open')).toBeFalsy;
-    });
-  });
-
-  context('show aspect name form', function() {
-    beforeEach(function() {
-      this.button = $('#change_aspect_name');
-    });
-
-    it('shows the form', function() {
-      expect($('#aspect_name_form').css('display')).toBe('none');
-      this.button.trigger('click');
-      expect($('#aspect_name_form').css('display')).not.toBe('none');
-    });
-
-    it('hides the aspect name', function() {
-      expect($('.header > h3').css('display')).not.toBe('none');
-      this.button.trigger('click');
-      expect($('.header > h3').css('display')).toBe('none');
-    });
-  });
-
-  context('add contact to aspect', function() {
-    beforeEach(function() {
-      this.contact = $('#people_stream .stream_element').last();
-      this.button = this.contact.find('.contact_add-to-aspect');
-      this.person_id = this.button.attr('data-person_id');
-      this.aspect_id = this.button.attr('data-aspect_id');
-    });
-
-    it('sends a correct ajax request', function() {
-      jasmine.Ajax.install();
-      $('.contact_add-to-aspect',this.contact).trigger('click');
-      var obj = $.parseJSON(jasmine.Ajax.requests.mostRecent().params);
-      expect(obj.person_id).toBe(this.person_id);
-      expect(obj.aspect_id).toBe(this.aspect_id);
-    });
-
-    it('adds a membership id to the contact', function() {
-      jasmine.Ajax.install();
-      $('.contact_add-to-aspect',this.contact).trigger('click');
-      jasmine.Ajax.requests.mostRecent().response({
-        status: 200, // success
-        responseText: '{ "id": 42 }'
-      });
-      expect(this.button.attr('data-membership_id')).toBe('42');
-    });
-
-    it('displays a flash message on errors', function(){
-      jasmine.Ajax.install();
-      $('.contact_add-to-aspect',this.contact).trigger('click');
-      jasmine.Ajax.requests.mostRecent().response({
-        status: 400, // fail
-      });
-      expect($('[id^="flash"]')).toBeErrorFlashMessage(
-        Diaspora.I18n.t(
-          'contacts.error_add',
-          {name: this.contact.find('.name').text()}
-        )
-      );
-    });
-
-    it('changes the appearance of the contact', function() {
-      expect(this.button.hasClass('contact_add-to-aspect')).toBeTruethy;
-      expect(this.button.hasClass('circled-cross')).toBeTruethy;
-      expect(this.contact.hasClass('in_aspect')).toBeTruethy;
-      expect(this.button.hasClass('contact_remove-from-aspect')).toBeFalsy;
-      expect(this.button.hasClass('circled-plus')).toBeFalsy;
-      expect(this.button.attr('data-original-title')).toBe(
-        Diaspora.I18n.t('contacts.add_contact')
-      );
-      jasmine.Ajax.install();
-      $('.contact_add-to-aspect',this.contact).trigger('click');
-      jasmine.Ajax.requests.mostRecent().response({
-        status: 200, // success
-        responseText: '{ "id": 42 }'
-      });
-      expect(this.button.hasClass('contact_add-to-aspect')).toBeFalsy;
-      expect(this.button.hasClass('circled-cross')).toBeFalsy;
-      expect(this.contact.hasClass('in_aspect')).toBeFalsy;
-      expect(this.button.hasClass('contact_remove-from-aspect')).toBeTruethy;
-      expect(this.button.hasClass('circled-plus')).toBeTruethy;
-      expect(this.button.attr('data-original-title')).toBe(
-        Diaspora.I18n.t('contacts.remove_contact')
-      );
-    });
-  });
-
-  context('remove contact from aspect', function() {
-    beforeEach(function() {
-      this.contact = $('#people_stream .stream_element').first();
-      this.button = this.contact.find('.contact_remove-from-aspect');
-      this.person_id = this.button.attr('data-person_id');
-      this.aspect_id = this.button.attr('data-aspect_id');
-      this.membership_id = this.button.attr('data-membership_id');
-
-    });
-
-    it('sends a correct ajax request', function() {
-      jasmine.Ajax.install();
-      $('.contact_remove-from-aspect',this.contact).trigger('click');
-      expect(jasmine.Ajax.requests.mostRecent().url).toBe(
-        "/aspect_memberships/"+this.membership_id
-      );
-    });
-
-    it('removes the membership id from the contact', function() {
-      jasmine.Ajax.install();
-      $('.contact_remove-from-aspect',this.contact).trigger('click');
-      jasmine.Ajax.requests.mostRecent().response({
-        status: 200, // success
-        responseText: '{}'
-      });
-      expect(this.button.attr('data-membership_id')).toBe(undefined);
-    });
-
-    it('displays a flash message on errors', function(){
-      jasmine.Ajax.install();
-      $('.contact_remove-from-aspect',this.contact).trigger('click');
-      jasmine.Ajax.requests.mostRecent().response({
-        status: 400, // fail
-      });
-      expect($('[id^="flash"]')).toBeErrorFlashMessage(
-        Diaspora.I18n.t(
-          'contacts.error_remove',
-          {name: this.contact.find('.name').text()}
-        )
-      );
-    });
-
-    it('changes the appearance of the contact', function() {
-      expect(this.button.hasClass('contact_add-to-aspect')).toBeFalsy;
-      expect(this.button.hasClass('circled-cross')).toBeFalsy;
-      expect(this.contact.hasClass('in_aspect')).toBeFalsy;
-      expect(this.button.hasClass('contact_remove-from-aspect')).toBeTruethy;
-      expect(this.button.hasClass('circled-plus')).toBeTruethy;
-      expect(this.button.attr('data-original-title')).toBe(
-        Diaspora.I18n.t('contacts.remove_contact')
-      );
-
-      jasmine.Ajax.install();
-      $('.contact_remove-from-aspect',this.contact).trigger('click');
-      jasmine.Ajax.requests.mostRecent().response({
-        status: 200, // success
-        responseText: '{}'
-      });
-
-      expect(this.button.hasClass('contact_add-to-aspect')).toBeTruethy;
-      expect(this.button.hasClass('circled-cross')).toBeTruethy;
-      expect(this.contact.hasClass('in_aspect')).toBeTruethy;
-      expect(this.button.hasClass('contact_remove-from-aspect')).toBeFalsy;
-      expect(this.button.hasClass('circled-plus')).toBeFalsy;
-      expect(this.button.attr('data-original-title')).toBe(
-        Diaspora.I18n.t('contacts.add_contact')
-      );
-    });
-  });
-
-  context('search contact list', function() {
-    beforeEach(function() {
-      this.searchinput = $('#contact_list_search');
-      this.username = $('.stream_element .name').first().text();
-    });
-
-    it('filters the contact list by name', function() {
-      expect($('.stream_element').length).toBeGreaterThan(1);
-      this.searchinput.val(this.username);
-      this.searchinput.trigger('keyup');
-      expect($('.stream_element:visible').length).toBe(1);
-      expect($('.stream_element:visible .name').first().text()).toBe(this.username);
-    });
-  });
-
-});
diff --git a/spec/presenters/aspect_membership_presenter_spec.rb b/spec/presenters/aspect_membership_presenter_spec.rb
new file mode 100644
index 0000000000..86d25de66e
--- /dev/null
+++ b/spec/presenters/aspect_membership_presenter_spec.rb
@@ -0,0 +1,15 @@
+require 'spec_helper'
+
+describe AspectMembershipPresenter do
+  before do
+    @am = alice.aspects.where(:name => "generic").first.aspect_memberships.first
+    @presenter = AspectMembershipPresenter.new(@am)
+  end
+
+  describe '#base_hash' do
+    it 'works' do
+      expect(@presenter.base_hash).to be_present
+    end
+  end
+
+end
diff --git a/spec/presenters/contact_presenter_spec.rb b/spec/presenters/contact_presenter_spec.rb
new file mode 100644
index 0000000000..2a1e0cefae
--- /dev/null
+++ b/spec/presenters/contact_presenter_spec.rb
@@ -0,0 +1,26 @@
+require 'spec_helper'
+
+describe ContactPresenter do
+  before do
+    @presenter = ContactPresenter.new(alice.contact_for(bob.person))
+  end
+
+  describe '#base_hash' do
+    it 'works' do
+      expect(@presenter.base_hash).to be_present
+    end
+  end
+
+  describe '#full_hash' do
+    it 'works' do
+      expect(@presenter.full_hash).to be_present
+    end
+  end
+
+  describe '#full_hash_with_person' do
+    it 'works' do
+      expect(@presenter.full_hash_with_person).to be_present
+    end
+  end
+
+end
-- 
GitLab