diff --git a/app/assets/javascripts/app/pages/contacts.js b/app/assets/javascripts/app/pages/contacts.js
index 7c041026ce1ff5ffbbd67176bd391819905c4ae6..eca20fa330ae908d3b09eae6180a094f91043944 100644
--- a/app/assets/javascripts/app/pages/contacts.js
+++ b/app/assets/javascripts/app/pages/contacts.js
@@ -8,7 +8,6 @@ app.pages.Contacts = Backbone.View.extend({
     "click #contacts_visibility_toggle" : "toggleContactVisibility",
     "click #chat_privilege_toggle" : "toggleChatPrivilege",
     "click #change_aspect_name" : "showAspectNameForm",
-    "keyup #contact_list_search" : "searchContactList",
     "click .conversation_button": "showMessageModal",
     "click #invitations-button": "showInvitationsModal"
   },
@@ -79,10 +78,6 @@ app.pages.Contacts = Backbone.View.extend({
     $(".header > h3").show();
   },
 
-  searchContactList: function(e) {
-    this.stream.search($(e.target).val());
-  },
-
   showMessageModal: function(){
     app.helpers.showModal("#conversationModal");
   },
diff --git a/app/assets/javascripts/app/router.js b/app/assets/javascripts/app/router.js
index 6c27e8b5c634d31f82e4a10197fbcb148be323c7..5f8a3cc9640de5de36dde93b60848cc88171783d 100644
--- a/app/assets/javascripts/app/router.js
+++ b/app/assets/javascripts/app/router.js
@@ -81,13 +81,14 @@ app.Router = Backbone.Router.extend({
     ).render();
   },
 
-  contacts: function() {
+  contacts: function(params) {
     app.aspect = new app.models.Aspect(gon.preloads.aspect);
     this._loadContacts();
 
     var stream = new app.views.ContactStream({
       collection: app.contacts,
-      el: $(".stream.contacts #contact_stream")
+      el: $(".stream.contacts #contact_stream"),
+      urlParams: params
     });
 
     app.page = new app.pages.Contacts({stream: stream});
diff --git a/app/assets/javascripts/app/views/contact_stream_view.js b/app/assets/javascripts/app/views/contact_stream_view.js
index ad93301bd809be1ff5c92f97dee0d5e0ebd022cf..cbc254c37605b0b255843fee8d7dac7e34e50977 100644
--- a/app/assets/javascripts/app/views/contact_stream_view.js
+++ b/app/assets/javascripts/app/views/contact_stream_view.js
@@ -1,77 +1,79 @@
 // @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();
+  initialize: function(opts) {
+    this.page = 1;
     var throttledScroll = _.throttle(_.bind(this.infScroll, this), 200);
     $(window).scroll(throttledScroll);
-    this.on('renderContacts', this.renderContacts, this);
+    this.on("fetchContacts", this.fetchContacts, this);
+    this.urlParams = opts.urlParams;
   },
 
   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();
-    }
+    this.fetchContacts();
   },
 
-  renderContacts: function() {
+  fetchContacts: 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);
+    $("#paginate .loader").removeClass("hidden");
+    $.ajax(this._fetchUrl(), {
+      context: this
+    }).success(function(response) {
+      if (response.length === 0) {
+        this.onEmptyResponse();
+      } else {
+        this.appendContactViews(response);
+        this.page++;
+      }
     });
+  },
 
-    var size = _.size(this.resultList);
-    if( this.itemCount + this.perPage >= size ){
-      this.itemCount = size;
-      this.off('renderContacts');
-    } else {
-      this.itemCount += this.perPage;
+  _fetchUrl: function() {
+    var url = Routes.contacts({format: "json", page: this.page});
+    if (this.urlParams) {
+      url += "&" + this.urlParams;
     }
-    this.$el.append(content);
-    this.$el.removeClass("loading");
+    return url;
   },
 
-  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();
+  onEmptyResponse: function() {
+    if (this.collection.length === 0) {
+      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);
     }
+    this.off("fetchContacts");
+    this.$el.removeClass("loading");
+    $("#paginate .loader").addClass("hidden");
+  },
+
+  appendContactViews: function(contacts) {
+    var content = document.createDocumentFragment();
+    contacts.forEach(function(contactData) {
+      var contact = new app.models.Contact(contactData);
+      this.collection.add(contact);
+      var view = new app.views.Contact({model: contact});
+      content.appendChild(view.render().el);
+    }.bind(this));
+    this.$el.append(content);
+    this.$el.removeClass("loading");
+    $("#paginate .loader").addClass("hidden");
   },
 
   infScroll: function() {
-    if( this.$el.hasClass('loading') ) return;
+    if (this.$el.hasClass("loading")) {
+      return;
+    }
 
     var distanceTop = $(window).height() + $(window).scrollTop(),
         distanceBottom = $(document).height() - distanceTop;
-    if(distanceBottom < 300) this.trigger('renderContacts');
+    if (distanceBottom < 300) {
+      this.trigger("fetchContacts");
+    }
   }
 });
 // @license-end
diff --git a/app/assets/stylesheets/contacts.scss b/app/assets/stylesheets/contacts.scss
index d5025275703e6ad5d02d77eade32356b07284d59..aaef928f7cac9ea9af6f07a555e5f13348b9344a 100644
--- a/app/assets/stylesheets/contacts.scss
+++ b/app/assets/stylesheets/contacts.scss
@@ -10,8 +10,13 @@
       margin-bottom: 11px;
       margin-top: 11px;
     }
+  }
 
-    .aspect-controls { margin: 7px -10px 7px 0; }
+  .stream.contacts .aspect-controls {
+    margin-bottom: 7px;
+    margin-left: 30px;
+    margin-right: -10px;
+    margin-top: 7px;
   }
 }
 
@@ -23,7 +28,7 @@
         display: none;
       }
       #contact_list_search {
-        margin: 11px 30px 0 0;
+        margin: 11px 0 0;
         width: 150px;
         &:focus { width: 250px; }
       }
diff --git a/app/controllers/contacts_controller.rb b/app/controllers/contacts_controller.rb
index d3734da9b765ba0fddbd0c273b634791b03d824c..a0be1dcec673b2d8b4e8d3e5b20014d408261c9f 100644
--- a/app/controllers/contacts_controller.rb
+++ b/app/controllers/contacts_controller.rb
@@ -14,9 +14,13 @@ class ContactsController < ApplicationController
       # Used by the mobile site
       format.mobile { set_up_contacts_mobile }
 
-      # Used for mentions in the publisher
+      # Used for mentions in the publisher and pagination on the contacts page
       format.json {
-        @people = Person.search(params[:q], current_user, only_contacts: true).limit(15)
+        @people = if params[:q].present?
+                    Person.search(params[:q], current_user, only_contacts: true).limit(15)
+                  else
+                    set_up_contacts_json
+                  end
         render json: @people
       }
     end
@@ -30,30 +34,54 @@ class ContactsController < ApplicationController
   private
 
   def set_up_contacts
+    if params[:a_id].present?
+      @aspect = current_user.aspects.find(params[:a_id])
+      gon.preloads[:aspect] = AspectPresenter.new(@aspect).as_json
+    end
+    @contacts_size = current_user.contacts.size
+  end
+
+  def set_up_contacts_json
     type = params[:set].presence
-    type ||= "by_aspect" if params[:a_id].present?
+    if params[:a_id].present?
+      type ||= "by_aspect"
+      @aspect = current_user.aspects.find(params[:a_id])
+    end
     type ||= "receiving"
-
-    @contacts = contacts_by_type(type)
-    @contacts_size = @contacts.length
-    gon.preloads[:contacts] = @contacts.map {|c| ContactPresenter.new(c, current_user).full_hash_with_person }
+    contacts_by_type(type).paginate(page: params[:page], per_page: 25)
+                          .map {|c| ContactPresenter.new(c, current_user).full_hash_with_person }
   end
 
   def contacts_by_type(type)
-    case type
+    order = ["profiles.first_name ASC", "profiles.last_name ASC", "profiles.diaspora_handle ASC"]
+    contacts = case type
       when "all"
+        order.unshift "receiving DESC"
         current_user.contacts
       when "only_sharing"
         current_user.contacts.only_sharing
       when "receiving"
         current_user.contacts.receiving
       when "by_aspect"
-        @aspect = current_user.aspects.find(params[:a_id])
-        gon.preloads[:aspect] = AspectPresenter.new(@aspect).as_json
-        current_user.contacts
+        order.unshift "contact_id IS NOT NULL DESC"
+        contacts_by_aspect(@aspect.id)
       else
         raise ArgumentError, "unknown type #{type}"
       end
+    contacts.includes(person: :profile)
+            .order(order)
+  end
+
+  def contacts_by_aspect(aspect_id)
+    contacts = current_user.contacts.arel_table
+    aspect_memberships = AspectMembership.arel_table
+    current_user.contacts.joins(
+      contacts.outer_join(aspect_memberships).on(
+        aspect_memberships[:aspect_id].eq(aspect_id).and(
+          aspect_memberships[:contact_id].eq(contacts[:id])
+        )
+      ).join_sources
+    )
   end
 
   def set_up_contacts_mobile
diff --git a/app/views/contacts/_header.html.haml b/app/views/contacts/_header.html.haml
index e6059e0051304bf74406a904292c24941063aa99..d8d323fb121907ac3cd381f57b233a80073519aa 100644
--- a/app/views/contacts/_header.html.haml
+++ b/app/views/contacts/_header.html.haml
@@ -20,7 +20,11 @@
       = link_to @aspect, method: "delete", data: { confirm: t("aspects.edit.confirm_remove_aspect") }, class: "delete contacts_button", id: "delete_aspect" do
         %i.entypo-trash.contacts-header-icon{title: t("delete")}
     .pull-right.contact-list-search
-      = search_field_tag :contact_search, "", id: "contact_list_search", class: "search-query form-control",  placeholder: t("contacts.index.user_search")
+      %form#contact-search-form{role: "search", method: "get", action: "/search"}
+        = search_field_tag :q, "",
+          id:          "contact_list_search",
+          class:       "search-query form-control",
+          placeholder: t("contacts.index.user_search")
     %h3
       %span#aspect_name
         = @aspect.name
@@ -32,6 +36,13 @@
         = aspect.submit t('aspects.edit.update'), 'data-disable-with' => t('aspects.edit.updating'), class: "btn btn-default"
 
   - else
+    .pull-right.contact-list-search
+      %form#contact-search-form{role: "search", method: "get", action: "/search"}
+        = search_field_tag :q, "",
+          id:          "contact_list_search",
+          class:       "search-query form-control",
+          placeholder: t("contacts.index.user_search")
+
     %h3
       - case params["set"]
         - when "only_sharing"
diff --git a/app/views/contacts/index.html.haml b/app/views/contacts/index.html.haml
index ebc6d2e43b8d6d5ddc19ce52c0b8f94a80a59d5a..2a8de4f3265af6941543513f12cf5728e30c3730 100644
--- a/app/views/contacts/index.html.haml
+++ b/app/views/contacts/index.html.haml
@@ -29,6 +29,10 @@
               .btn.btn-link{ 'data-toggle' => 'modal' }
                 = t('invitations.new.invite_someone_to_join')
 
+      #paginate
+        %span.loader.hidden
+          .spinner
+
 -if @aspect
   #new_conversation_pane
     = render 'shared/modal',
diff --git a/spec/controllers/contacts_controller_spec.rb b/spec/controllers/contacts_controller_spec.rb
index 27f9b9def179b0cf737e7506bdb4d89a22de1620..7a8ab7a13c80cc47602f9c4022383c8f6b5462dc 100644
--- a/spec/controllers/contacts_controller_spec.rb
+++ b/spec/controllers/contacts_controller_spec.rb
@@ -24,51 +24,97 @@ describe ContactsController, :type => :controller do
         expect(response).to be_success
       end
 
-      it "assigns contacts" do
+      it "doesn't assign contacts" do
         get :index
         contacts = assigns(:contacts)
-        expect(contacts.to_set).to eq(bob.contacts.to_set)
-      end
-
-      it "shows only contacts a user is sharing with" do
-        contact = bob.contacts.first
-        contact.update_attributes(:sharing => false)
-
-        get :index
-        contacts = assigns(:contacts)
-        expect(contacts.to_set).to eq(bob.contacts.receiving.to_set)
-      end
-
-      it "shows all contacts (sharing and receiving)" do
-        contact = bob.contacts.first
-        contact.update_attributes(:sharing => false)
-
-        get :index, :set => "all"
-        contacts = assigns(:contacts)
-        expect(contacts.to_set).to eq(bob.contacts.to_set)
+        expect(contacts).to be_nil
       end
     end
 
     context "format json" do
-      before do
-        @person1 = FactoryGirl.create(:person)
-        bob.share_with(@person1, bob.aspects.first)
-        @person2 = FactoryGirl.create(:person)
-      end
-
-      it "succeeds" do
-        get :index, q: @person1.first_name, format: "json"
-        expect(response).to be_success
-      end
-
-      it "responds with json" do
-        get :index, q: @person1.first_name, format: "json"
-        expect(response.body).to eq([@person1].to_json)
+      context "for the contacts search" do
+        before do
+          @person1 = FactoryGirl.create(:person)
+          bob.share_with(@person1, bob.aspects.first)
+          @person2 = FactoryGirl.create(:person)
+        end
+
+        it "succeeds" do
+          get :index, q: @person1.first_name, format: "json"
+          expect(response).to be_success
+        end
+
+        it "responds with json" do
+          get :index, q: @person1.first_name, format: "json"
+          expect(response.body).to eq([@person1].to_json)
+        end
+
+        it "only returns contacts" do
+          get :index, q: @person2.first_name, format: "json"
+          expect(response.body).to eq([].to_json)
+        end
       end
 
-      it "only returns contacts" do
-        get :index, q: @person2.first_name, format: "json"
-        expect(response.body).to eq([].to_json)
+      context "for pagination on the contacts page" do
+        context "without parameters" do
+          it "returns contacts" do
+            get :index, format: "json", page: "1"
+            contact_ids = JSON.parse(response.body).map {|c| c["id"] }
+            expect(contact_ids.to_set).to eq(bob.contacts.map(&:id).to_set)
+          end
+
+          it "returns only contacts which are receiving (the user is sharing with them)" do
+            contact = bob.contacts.first
+            contact.update_attributes(receiving: false)
+
+            get :index, format: "json", page: "1"
+            contact_ids = JSON.parse(response.body).map {|c| c["id"] }
+            expect(contact_ids.to_set).to eq(bob.contacts.receiving.map(&:id).to_set)
+            expect(contact_ids).not_to include(contact.id)
+          end
+        end
+
+        context "set: all" do
+          before do
+            contact = bob.contacts.first
+            contact.update_attributes(receiving: false)
+          end
+
+          it "returns all contacts (sharing and receiving)" do
+            get :index, format: "json", page: "1", set: "all"
+            contact_ids = JSON.parse(response.body).map {|c| c["id"] }
+            expect(contact_ids.to_set).to eq(bob.contacts.map(&:id).to_set)
+          end
+
+          it "sorts contacts by receiving status" do
+            get :index, format: "json", page: "1", set: "all"
+            contact_ids = JSON.parse(response.body).map {|c| c["id"] }
+            expect(contact_ids).to eq(bob.contacts.order("receiving DESC").map(&:id))
+            expect(contact_ids.last).to eq(bob.contacts.first.id)
+          end
+        end
+
+        context "with an aspect id" do
+          before do
+            @aspect = bob.aspects.create(name: "awesome contacts")
+            @person = FactoryGirl.create(:person)
+            bob.share_with(@person, @aspect)
+          end
+
+          it "returns all contacts" do
+            get :index, format: "json", a_id: @aspect.id, page: "1"
+            contact_ids = JSON.parse(response.body).map {|c| c["id"] }
+            expect(contact_ids.to_set).to eq(bob.contacts.map(&:id).to_set)
+          end
+
+          it "sorts contacts by aspect memberships" do
+            get :index, format: "json", a_id: @aspect.id, page: "1"
+            expect(JSON.parse(response.body).first["person"]["id"]).to eq(@person.id)
+
+            get :index, format: "json", a_id: bob.aspects.first.id, page: "1"
+            expect(JSON.parse(response.body).first["person"]["id"]).not_to eq(@person.id)
+          end
+        end
       end
     end
   end
diff --git a/spec/controllers/jasmine_fixtures/contacts_spec.rb b/spec/controllers/jasmine_fixtures/contacts_spec.rb
index 261d3a88d69a16735d16c530ba59d82bc19af2b8..3e6cb3c85e1b3872fd802799c0b5708214a7ff26 100644
--- a/spec/controllers/jasmine_fixtures/contacts_spec.rb
+++ b/spec/controllers/jasmine_fixtures/contacts_spec.rb
@@ -17,7 +17,11 @@ describe ContactsController, :type => :controller do
     it "generates the aspects_manage fixture", :fixture => true do
       get :index, :a_id => @aspect.id
       save_fixture(html_for("body"), "aspects_manage")
-      save_fixture(controller.gon.preloads[:contacts].to_json, "aspects_manage_contacts_json")
+    end
+
+    it "generates the aspects_manage_contacts_json fixture", fixture: true do
+      get :index, format: :json, a_id: @aspect.id, page: "1"
+      save_fixture(response.body, "aspects_manage_contacts_json")
     end
 
     it "generates the contacts_json fixture", :fixture => true do
diff --git a/spec/javascripts/app/pages/contacts_spec.js b/spec/javascripts/app/pages/contacts_spec.js
index 961378a5c80af0a25c9051f443d37b69964b6322..a40b899d22eaacf9cf407af1a448ad311a1a5d44 100644
--- a/spec/javascripts/app/pages/contacts_spec.js
+++ b/spec/javascripts/app/pages/contacts_spec.js
@@ -88,19 +88,6 @@ describe("app.pages.Contacts", function(){
     });
   });
 
-  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");
-    });
-  });
-
   describe("updateBadgeCount", function() {
     it("increases the badge count of an aspect", function() {
       var aspect = $("#aspect_nav .aspect").eq(0);
diff --git a/spec/javascripts/app/views/contact_stream_view_spec.js b/spec/javascripts/app/views/contact_stream_view_spec.js
index 955dd2e7b16f7feac26721b8fd83e65b22fdb800..7ae93bb5352e6252b6a2a589f9d913fd16057d1e 100644
--- a/spec/javascripts/app/views/contact_stream_view_spec.js
+++ b/spec/javascripts/app/views/contact_stream_view_spec.js
@@ -2,76 +2,199 @@ 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.contacts = new app.collections.Contacts();
+    this.contactsData = $.parseJSON(spec.readFixture("contacts_json"));
+    app.aspect = new app.models.Aspect(this.contactsData[0].aspect_memberships[0].aspect);
     this.view = new app.views.ContactStream({
       collection : this.contacts,
-      el: $('.stream.contacts #contact_stream')
+      el: $(".stream.contacts #contact_stream"),
+      urlParams: "set=all"
     });
-
-    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});
+      new app.views.ContactStream({collection: this.contacts});
       expect($.fn.scroll).toHaveBeenCalled();
     });
+
+    it("binds 'fetchContacts'", function() {
+      spyOn(app.views.ContactStream.prototype, "fetchContacts");
+      this.view = new app.views.ContactStream({collection: this.contacts});
+      this.view.trigger("fetchContacts");
+      expect(app.views.ContactStream.prototype.fetchContacts).toHaveBeenCalled();
+    });
+
+    it("sets the current page for pagination to 1", function() {
+      expect(this.view.page).toBe(1);
+    });
+
+    it("sets urlParams to the given value", function() {
+      expect(this.view.urlParams).toBe("set=all");
+    });
   });
 
-  describe("search", function() {
-    it("filters the contacts", function() {
+  describe("render", function() {
+    it("calls fetchContacts", function() {
+      spyOn(this.view, "fetchContacts");
       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");
+      expect(this.view.fetchContacts).toHaveBeenCalled();
     });
   });
 
-  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);
+  describe("fetchContacts", function() {
+    it("adds the loading class", function() {
+      expect(this.view.$el).not.toHaveClass("loading");
+      this.view.fetchContacts();
+      expect(this.view.$el).toHaveClass("loading");
     });
 
-    it("triggers renderContacts when the user is at the bottom of the page", function() {
-      this.view.infScroll();
-      expect(this.fn).toHaveBeenCalled();
+    it("displays the loading spinner", function() {
+      expect($("#paginate .loader")).toHaveClass("hidden");
+      this.view.fetchContacts();
+      expect($("#paginate .loader")).not.toHaveClass("hidden");
+    });
+
+    it("calls $.ajax with the URL given by _fetchUrl", function() {
+      spyOn(this.view, "_fetchUrl").and.returnValue("/myAwesomeFetchUrl?foo=bar");
+      this.view.fetchContacts();
+      expect(jasmine.Ajax.requests.mostRecent().url).toBe("/myAwesomeFetchUrl?foo=bar");
+    });
+
+    it("calls onEmptyResponse on an empty response", function() {
+      spyOn(this.view, "onEmptyResponse");
+      this.view.fetchContacts();
+      jasmine.Ajax.requests.mostRecent().respondWith({status: 200, responseText: JSON.stringify([])});
+      expect(this.view.onEmptyResponse).toHaveBeenCalled();
+    });
+
+    it("calls appendContactViews on a non-empty response", function() {
+      spyOn(this.view, "appendContactViews");
+      this.view.fetchContacts();
+      jasmine.Ajax.requests.mostRecent().respondWith({status: 200, responseText: JSON.stringify(this.contactsData)});
+      expect(this.view.appendContactViews).toHaveBeenCalledWith(this.contactsData);
+    });
+
+    it("increases the current page on a non-empty response", function() {
+      this.view.page = 42;
+      this.view.fetchContacts();
+      jasmine.Ajax.requests.mostRecent().respondWith({status: 200, responseText: JSON.stringify(this.contactsData)});
+      expect(this.view.page).toBe(43);
     });
   });
 
-  describe("render", function() {
-    beforeEach(function() {
-      spyOn(this.view, "renderContacts");
+  describe("_fetchUrl", function() {
+    it("returns the correct URL to fetch contacts", function() {
+      this.view.page = 15;
+      this.view.urlParams = undefined;
+      expect(this.view._fetchUrl()).toBe("/contacts.json?page=15");
     });
 
-    it("calls renderContacts", function() {
-      this.view.render();
-      expect(this.view.renderContacts).toHaveBeenCalled();
+    it("appends urlParams if those are set", function() {
+      this.view.page = 23;
+      expect(this.view._fetchUrl()).toBe("/contacts.json?page=23&set=all");
     });
   });
 
-  describe("renderContacts", function() {
-    beforeEach(function() {
-      this.view.off("renderContacts");
-      this.view.renderContacts();
+  describe("onEmptyResponse", function() {
+    context("with an empty collection", function() {
+      it("adds a 'no contacts' div", function() {
+        this.view.onEmptyResponse();
+        expect(this.view.$("#no_contacts").text().trim()).toBe(Diaspora.I18n.t("contacts.search_no_results"));
+      });
+
+      it("hides the loading spinner", function() {
+        this.view.$el.addClass("loading");
+        $("#paginate .loader").removeClass("hidden");
+        this.view.onEmptyResponse();
+        expect(this.view.$el).not.toHaveClass("loading");
+        expect($("#paginate .loader")).toHaveClass("hidden");
+      });
+
+      it("unbinds 'fetchContacts'", function() {
+        spyOn(this.view, "off");
+        this.view.onEmptyResponse();
+        expect(this.view.off).toHaveBeenCalledWith("fetchContacts");
+      });
+    });
+
+    context("with a non-empty collection", function() {
+      beforeEach(function() {
+        this.view.collection.add(factory.contact());
+      });
+
+      it("adds no 'no contacts' div", function() {
+        this.view.onEmptyResponse();
+        expect(this.view.$("#no_contacts").length).toBe(0);
+      });
+
+      it("hides the loading spinner", function() {
+        this.view.$el.addClass("loading");
+        $("#paginate .loader").removeClass("hidden");
+        this.view.onEmptyResponse();
+        expect(this.view.$el).not.toHaveClass("loading");
+        expect($("#paginate .loader")).toHaveClass("hidden");
+      });
+
+      it("unbinds 'fetchContacts'", function() {
+        spyOn(this.view, "off");
+        this.view.onEmptyResponse();
+        expect(this.view.off).toHaveBeenCalledWith("fetchContacts");
+      });
+    });
+  });
+
+  describe("appendContactViews", function() {
+    it("hides the loading spinner", function() {
+      this.view.$el.addClass("loading");
+      $("#paginate .loader").removeClass("hidden");
+      this.view.appendContactViews(this.contactsData);
+      expect(this.view.$el).not.toHaveClass("loading");
+      expect($("#paginate .loader")).toHaveClass("hidden");
     });
 
-    it("renders perPage contacts", function() {
-      expect(this.view.$el.find('.stream_element.contact').length).toBe(1);
+    it("adds all contacts to an empty collection", function() {
+      expect(this.view.collection.length).toBe(0);
+      this.view.appendContactViews(this.contactsData);
+      expect(this.view.collection.length).toBe(this.contactsData.length);
+      expect(this.view.collection.pluck("id")).toEqual(_.pluck(this.contactsData, "id"));
     });
 
-    it("renders more contacts when called a second time", function() {
-      this.view.renderContacts();
-      expect(this.view.$el.find('.stream_element.contact').length).toBe(2);
+    it("appends contacts to an existing collection", function() {
+      this.view.collection.add(this.contactsData[0]);
+      expect(this.view.collection.length).toBe(1);
+      this.view.appendContactViews(_.rest(this.contactsData));
+      expect(this.view.collection.length).toBe(this.contactsData.length);
+      expect(this.view.collection.pluck("id")).toEqual(_.pluck(this.contactsData, "id"));
+    });
+
+    it("renders all added contacts", function() {
+      expect(this.view.$(".stream_element.contact").length).toBe(0);
+      this.view.appendContactViews(this.contactsData);
+      expect(this.view.$(".stream_element.contact").length).toBe(this.contactsData.length);
+    });
+
+    it("appends contacts to an existing contact list", function() {
+      this.view.appendContactViews([this.contactsData[0]]);
+      expect(this.view.$(".stream_element.contact").length).toBe(1);
+      this.view.appendContactViews(_.rest(this.contactsData));
+      expect(this.view.$(".stream_element.contact").length).toBe(this.contactsData.length);
+    });
+  });
+
+  describe("infScroll", function() {
+    beforeEach(function() {
+      this.view.off("fetchContacts");
+      this.fn = jasmine.createSpy();
+      this.view.on("fetchContacts", this.fn);
+      spyOn($.fn, "height").and.returnValue(0);
+      spyOn($.fn, "scrollTop").and.returnValue(100);
+    });
+
+    it("triggers fetchContacts when the user is at the bottom of the page", function() {
+      this.view.infScroll();
+      expect(this.fn).toHaveBeenCalled();
     });
   });
 });