Unverified Commit ce5e42c4 authored by Benjamin Neff's avatar Benjamin Neff

Merge pull request #7182 from svbergerem/spv-load-initial-interactions

Single post view interaction refactorings
parents 339dd276 04735ce9
......@@ -29,6 +29,7 @@ If so, please delete it since it will prevent the federation from working proper
* Use id as fallback when sorting posts [#7523](https://github.com/diaspora/diaspora/pull/7523)
* Remove no-posts-info when adding posts to the stream [#7523](https://github.com/diaspora/diaspora/pull/7523)
* Upgrade to rails 5.1 [#7514](https://github.com/diaspora/diaspora/pull/7514)
* Refactoring single post view interactions [#7182](https://github.com/diaspora/diaspora/pull/7182)
## Bug fixes
......
......@@ -48,12 +48,6 @@ app.models.Post = Backbone.Model.extend(_.extend({}, app.models.formatDateMixin,
var body = this.get("text").trim()
, newlineIdx = body.indexOf("\n");
return (newlineIdx > 0 ) ? body.substr(newlineIdx+1, body.length) : "";
},
//returns a promise
preloadOrFetch : function(){
var action = app.hasPreload("post") ? this.set(app.parsePreload("post")) : this.fetch();
return $.when(action);
}
}));
// @license-end
......
......@@ -3,10 +3,6 @@
//require ../post
app.models.Post.Interactions = Backbone.Model.extend({
url : function(){
return this.post.url() + "/interactions";
},
initialize : function(options){
this.post = options.post;
this.comments = new app.collections.Comments(this.get("comments"), {post : this.post});
......@@ -14,33 +10,16 @@ app.models.Post.Interactions = Backbone.Model.extend({
this.reshares = new app.collections.Reshares(this.get("reshares"), {post : this.post});
},
parse : function(resp){
this.comments.reset(resp.comments);
this.likes.reset(resp.likes);
this.reshares.reset(resp.reshares);
var comments = this.comments
, likes = this.likes
, reshares = this.reshares;
return {
comments : comments,
likes : likes,
reshares : reshares,
fetched : true
};
},
likesCount : function(){
return this.get("fetched") ? this.likes.models.length : this.get("likes_count");
return this.get("likes_count");
},
resharesCount : function(){
return this.get("fetched") ? this.reshares.models.length : this.get("reshares_count");
return this.get("reshares_count");
},
commentsCount : function(){
return this.get("fetched") ? this.comments.models.length : this.get("comments_count");
return this.get("comments_count");
},
userLike : function(){
......
......@@ -9,9 +9,8 @@ app.pages.SinglePostViewer = app.views.Base.extend({
},
initialize : function() {
this.model = new app.models.Post({ id : gon.post.id });
this.model.preloadOrFetch().done(_.bind(this.initViews, this));
this.model.interactions.fetch(); //async, yo, might want to throttle this later.
this.model = new app.models.Post(gon.post);
this.initViews();
},
initViews : function() {
......
......@@ -182,7 +182,7 @@ app.Router = Backbone.Router.extend({
},
singlePost: function(id) {
this.renderPage(function() { return new app.pages.SinglePostViewer({id: id}); });
this.renderPage(function() { return new app.pages.SinglePostViewer({id: id, el: $("#container")}); });
},
spotlight: function() {
......
......@@ -7,7 +7,9 @@ app.views.SinglePostCommentStream = app.views.CommentStream.extend({
this.CommentView = app.views.ExpandedComment;
$(window).on('hashchange',this.highlightPermalinkComment);
this.setupBindings();
this.model.comments.on("reset", this.render, this);
this.model.comments.fetch({success: function() {
setTimeout(this.highlightPermalinkComment, 0);
}.bind(this)});
},
highlightPermalinkComment: function() {
......@@ -17,14 +19,13 @@ app.views.SinglePostCommentStream = app.views.CommentStream.extend({
$(".highlighted").removeClass("highlighted");
element.addClass("highlighted");
var pos = element.offset().top - headerSize;
window.scroll(0, pos);
$("html,body").animate({scrollTop: pos});
}
},
postRenderTemplate: function() {
app.views.CommentStream.prototype.postRenderTemplate.apply(this);
this.$(".new-comment-form-wrapper").removeClass("hidden");
_.defer(this.highlightPermalinkComment);
},
presenter: function(){
......
......@@ -4,8 +4,15 @@ app.views.SinglePostInteractionCounts = app.views.Base.extend({
templateName: "single-post-viewer/single-post-interaction-counts",
tooltipSelector: ".avatar.micro",
events: {
"click #show-all-likes": "showAllLikes",
"click #show-all-reshares": "showAllReshares"
},
initialize: function() {
this.model.interactions.on("change", this.render, this);
this.model.interactions.likes.on("change", this.render, this);
this.model.interactions.reshares.on("change", this.render, this);
},
presenter: function() {
......@@ -15,8 +22,28 @@ app.views.SinglePostInteractionCounts = app.views.Base.extend({
reshares: interactions.reshares.toJSON(),
commentsCount: interactions.commentsCount(),
likesCount: interactions.likesCount(),
resharesCount: interactions.resharesCount()
resharesCount: interactions.resharesCount(),
showMoreLikes: interactions.likes.length < interactions.likesCount(),
showMoreReshares: interactions.reshares.length < interactions.resharesCount()
};
},
_showAll: function(interactionType, models) {
this.$("#show-all-" + interactionType).addClass("hidden");
this.$("#" + interactionType + " .loader").removeClass("hidden");
models.fetch({success: function() {
models.trigger("change");
}});
},
showAllLikes: function(evt) {
evt.preventDefault();
this._showAll("likes", this.model.interactions.likes);
},
showAllReshares: function(evt) {
evt.preventDefault();
this._showAll("reshares", this.model.interactions.reshares);
}
});
// @license-end
......@@ -152,5 +152,16 @@
.interaction-avatars {
overflow: hidden;
.author-name:focus,
.author-name:hover {
text-decoration: none;
}
.loader {
height: $line-height-computed;
vertical-align: text-bottom;
width: $line-height-computed;
}
}
}
......@@ -10,6 +10,12 @@
{{{personImage this "small" "micro"}}}
{{/linkToAuthor}}
{{/each}}
{{#if showMoreReshares}}
<div class="loader hidden">
<div class="spinner"></div>
</div>
<div id="show-all-reshares" class="btn btn-sm btn-link">{{t "show_all"}}</div>
{{/if}}
</div>
</div>
{{/if}}
......@@ -25,6 +31,12 @@
{{{personImage this "small" "micro"}}}
{{/linkToAuthor}}
{{/each}}
{{#if showMoreLikes}}
<div class="loader hidden">
<div class="spinner"></div>
</div>
<div id="show-all-likes" class="btn btn-sm btn-link">{{t "show_all"}}</div>
{{/if}}
</div>
</div>
{{/if}}
......
......@@ -22,11 +22,11 @@ class PostsController < ApplicationController
presenter = PostPresenter.new(post, current_user)
respond_to do |format|
format.html do
gon.post = presenter
gon.post = presenter.with_initial_interactions
render locals: {post: presenter}
end
format.mobile { render locals: {post: post} }
format.json { render json: presenter }
format.json { render json: presenter.with_interactions }
end
end
......@@ -39,16 +39,6 @@ class PostsController < ApplicationController
head :not_found
end
def interactions
respond_to do |format|
format.json {
post = post_service.find!(params[:id])
render json: PostInteractionPresenter.new(post, current_user)
}
format.any { head :not_acceptable }
end
end
def mentionable
respond_to do |format|
format.json {
......
......@@ -7,7 +7,7 @@ class ResharesController < ApplicationController
rescue ActiveRecord::RecordNotFound, ActiveRecord::RecordInvalid
render plain: I18n.t("reshares.create.error"), status: 422
else
render json: ExtremePostPresenter.new(reshare, current_user), status: 201
render json: PostPresenter.new(reshare, current_user).with_interactions, status: 201
end
def index
......
#this file should go away, hence the name that is so full of lulz
#post interactions should probably be a decorator, and used in very few places... maybe?
class ExtremePostPresenter
def initialize(post, current_user)
@post = post
@current_user = current_user
end
def as_json(options={})
post = PostPresenter.new(@post, @current_user)
interactions = PostInteractionPresenter.new(@post, @current_user)
post.as_json.merge!(:interactions => interactions.as_json)
end
end
\ No newline at end of file
......@@ -14,6 +14,20 @@ class PostPresenter < BasePresenter
.merge(non_directly_retrieved_attributes)
end
def with_interactions
interactions = PostInteractionPresenter.new(@post, current_user)
as_json.merge!(interactions: interactions.as_json)
end
def with_initial_interactions
as_json.tap do |post|
post[:interactions].merge!(
likes: LikeService.new(current_user).find_for_post(@post.id).limit(30).as_api_response(:backbone),
reshares: ReshareService.new(current_user).find_for_post(@post.id).limit(30).as_api_response(:backbone)
)
end
end
def metas_attributes
{
keywords: {name: "keywords", content: comma_separated_tags},
......
......@@ -28,6 +28,7 @@ en:
comma: ","
edit: "Edit"
no_results: "No results found"
show_all: "Show all"
admins:
dashboard:
......
......@@ -29,7 +29,6 @@ Rails.application.routes.draw do
resources :posts, only: %i(show destroy) do
member do
get :interactions
get :mentionable
end
......
require "spec_helper"
describe PostsController, type: :controller do
describe "#show" do
it "generates the post_json fixture", fixture: true do
post = alice.post(:status_message, text: "hello world", public: true)
get :show, params: {id: post.id}, format: :json
save_fixture(response.body, "post_json")
end
end
end
......@@ -129,38 +129,6 @@ describe PostsController, type: :controller do
end
end
describe "#interactions" do
context "user not signed in" do
it "returns a 401 for private posts and format json" do
get :interactions, params: {id: post.id}, format: :json
expect(response.status).to eq(401)
expect(JSON.parse(response.body)["error"]).to eq(I18n.t("devise.failure.unauthenticated"))
end
it "returns a 406 for private posts and format html" do
get :interactions, params: {id: post.id}
expect(response.status).to eq(406)
end
end
context "user signed in" do
before do
sign_in alice
end
it "shows interactions of a post as json" do
get :interactions, params: {id: post.id}, format: :json
expect(response.body).to eq(PostInteractionPresenter.new(post, alice).to_json)
end
it "returns a 406 for format html" do
sign_in alice
get :interactions, params: {id: post.id}
expect(response.status).to eq(406)
end
end
end
describe "#mentionable" do
context "with a user signed in" do
before do
......
describe("app.pages.SinglePostViewer", function(){
beforeEach(function() {
window.gon={};gon.post = {id: 42};
window.gon = {};
gon.post = $.parseJSON(spec.readFixture("post_json"));
this.view = new app.pages.SinglePostViewer();
});
......
......@@ -9,14 +9,6 @@ describe("app.views.SinglePostCommentStream", function() {
expect(this.view.CommentView).toBe(app.views.ExpandedComment);
});
it("calls render when the comments collection has been resetted", function() {
spyOn(app.views.SinglePostCommentStream.prototype, "render");
this.view.initialize();
expect(app.views.SinglePostCommentStream.prototype.render).not.toHaveBeenCalled();
this.post.comments.reset();
expect(app.views.SinglePostCommentStream.prototype.render).toHaveBeenCalled();
});
it("calls setupBindings", function() {
spyOn(app.views.SinglePostCommentStream.prototype, "setupBindings");
this.view.initialize();
......
describe("app.views.SinglePostInteractionCounts", function() {
beforeEach(function() {
this.post = factory.post();
this.post = factory.postWithInteractions();
this.view = new app.views.SinglePostInteractionCounts({model: this.post});
});
......@@ -12,5 +12,151 @@ describe("app.views.SinglePostInteractionCounts", function() {
this.post.interactions.trigger("change");
expect(app.views.SinglePostInteractionCounts.prototype.render).toHaveBeenCalled();
});
it("calls render when the likes change", function() {
spyOn(app.views.SinglePostInteractionCounts.prototype, "render");
this.view.initialize();
expect(app.views.SinglePostInteractionCounts.prototype.render).not.toHaveBeenCalled();
this.post.interactions.likes.trigger("change");
expect(app.views.SinglePostInteractionCounts.prototype.render).toHaveBeenCalled();
});
it("calls render when the reshares change", function() {
spyOn(app.views.SinglePostInteractionCounts.prototype, "render");
this.view.initialize();
expect(app.views.SinglePostInteractionCounts.prototype.render).not.toHaveBeenCalled();
this.post.interactions.reshares.trigger("change");
expect(app.views.SinglePostInteractionCounts.prototype.render).toHaveBeenCalled();
});
});
describe("render", function() {
it("doesn't show a #show-all-likes link if there are no additional likes", function() {
this.view.render();
expect(this.view.$("#show-all-likes").length).toBe(0);
});
it("shows a #show-all-likes link if there are additional likes", function() {
this.view.model.interactions.set("likes_count", this.view.model.interactions.likes.length + 1);
this.view.render();
expect(this.view.$("#show-all-likes").length).toBe(1);
});
it("doesn't show a #show-all-reshares link if there are no additional reshares", function() {
this.view.render();
expect(this.view.$("#show-all-reshares").length).toBe(0);
});
it("shows a #show-all-reshares link if there are additional reshares", function() {
this.view.model.interactions.set("reshares_count", this.view.model.interactions.reshares.length + 1);
this.view.render();
expect(this.view.$("#show-all-reshares").length).toBe(1);
});
});
describe("showAllLikes", function() {
it("is called when clicking #show-all-likes", function() {
spyOn(this.view, "showAllLikes");
this.view.delegateEvents();
this.view.model.interactions.set("likes_count", this.view.model.interactions.likes.length + 1);
this.view.render();
expect(this.view.showAllLikes).not.toHaveBeenCalled();
this.view.$("#show-all-likes").click();
expect(this.view.showAllLikes).toHaveBeenCalled();
});
it("calls _showAll", function() {
spyOn(this.view, "_showAll");
this.view.showAllLikes($.Event());
expect(this.view._showAll).toHaveBeenCalledWith("likes", this.view.model.interactions.likes);
});
});
describe("showAllReshares", function() {
it("is called when clicking #show-all-reshares", function() {
spyOn(this.view, "showAllReshares");
this.view.delegateEvents();
this.view.model.interactions.set("reshares_count", this.view.model.interactions.reshares.length + 1);
this.view.render();
expect(this.view.showAllReshares).not.toHaveBeenCalled();
this.view.$("#show-all-reshares").click();
expect(this.view.showAllReshares).toHaveBeenCalled();
});
it("calls _showAll", function() {
spyOn(this.view, "_showAll");
this.view.showAllReshares($.Event());
expect(this.view._showAll).toHaveBeenCalledWith("reshares", this.view.model.interactions.reshares);
});
});
describe("_showAll", function() {
beforeEach(function() {
this.view.model.interactions.set("likes_count", this.view.model.interactions.likes.length + 1);
this.view.model.interactions.set("reshares_count", this.view.model.interactions.reshares.length + 1);
this.view.render();
});
context("with likes", function() {
it("hides the #show-all-likes link", function() {
expect(this.view.$("#show-all-likes")).not.toHaveClass("hidden");
expect(this.view.$("#show-all-reshares")).not.toHaveClass("hidden");
this.view._showAll("likes", this.view.model.interactions.likes);
expect(this.view.$("#show-all-likes")).toHaveClass("hidden");
expect(this.view.$("#show-all-reshares")).not.toHaveClass("hidden");
});
it("shows the likes loader", function() {
expect(this.view.$("#likes .loader")).toHaveClass("hidden");
expect(this.view.$("#reshares .loader")).toHaveClass("hidden");
this.view._showAll("likes", this.view.model.interactions.likes);
expect(this.view.$("#likes .loader")).not.toHaveClass("hidden");
expect(this.view.$("#reshares .loader")).toHaveClass("hidden");
});
it("calls #fetch on the model", function() {
spyOn(this.view.model.interactions.likes, "fetch");
this.view._showAll("likes", this.view.model.interactions.likes);
expect(this.view.model.interactions.likes.fetch).toHaveBeenCalled();
});
it("triggers 'change' after a successfull fetch", function() {
spyOn(this.view.model.interactions.likes, "trigger");
this.view._showAll("likes", this.view.model.interactions.likes);
jasmine.Ajax.requests.mostRecent().respondWith({status: 200, responseText: "{\"id\": 1}"});
expect(this.view.model.interactions.likes.trigger).toHaveBeenCalledWith("change");
});
});
context("with reshares", function() {
it("hides the #show-all-reshares link", function() {
expect(this.view.$("#show-all-likes")).not.toHaveClass("hidden");
expect(this.view.$("#show-all-reshares")).not.toHaveClass("hidden");
this.view._showAll("reshares", this.view.model.interactions.reshares);
expect(this.view.$("#show-all-likes")).not.toHaveClass("hidden");
expect(this.view.$("#show-all-reshares")).toHaveClass("hidden");
});
it("shows the reshares loader", function() {
expect(this.view.$("#likes .loader")).toHaveClass("hidden");
expect(this.view.$("#reshares .loader")).toHaveClass("hidden");
this.view._showAll("reshares", this.view.model.interactions.reshares);
expect(this.view.$("#likes .loader")).toHaveClass("hidden");
expect(this.view.$("#reshares .loader")).not.toHaveClass("hidden");
});
it("calls #fetch on the model", function() {
spyOn(this.view.model.interactions.reshares, "fetch");
this.view._showAll("reshares", this.view.model.interactions.reshares);
expect(this.view.model.interactions.reshares.fetch).toHaveBeenCalled();
});
it("triggers 'change' after a successfull fetch", function() {
spyOn(this.view.model.interactions.reshares, "trigger");
this.view._showAll("reshares", this.view.model.interactions.reshares);
jasmine.Ajax.requests.mostRecent().respondWith({status: 200, responseText: "{\"id\": 1}"});
expect(this.view.model.interactions.reshares.trigger).toHaveBeenCalledWith("change");
});
});
});
});
......@@ -21,6 +21,16 @@ var factory = {
return _.extend(defaultAttrs, overrides);
},
reshare: function(overrides) {
var defaultAttrs = {
"created_at": "2012-01-04T00:55:30Z",
"author": this.author(),
"guid": this.guid(),
"id": this.id.next()
};
return _.extend(defaultAttrs, overrides);
},
aspectMembershipAttrs: function(overrides) {
var id = this.id.next();
var defaultAttrs = {
......@@ -207,6 +217,24 @@ var factory = {
return new app.models.Post(_.extend(defaultAttrs, overrides));
},
postWithInteractions: function(overrides) {
var likes = _.range(10).map(function() { return factory.like(); });
var reshares = _.range(15).map(function() { return factory.reshare(); });
var comments = _.range(20).map(function() { return factory.comment(); });
var defaultAttrs = _.extend(factory.postAttrs(), {
"author": this.author(),
"interactions": {
"reshares_count": 15,
"likes_count": 10,
"comments_count": 20,
"comments": comments,
"likes": likes,
"reshares": reshares
}
});
return new app.models.Post(_.extend(defaultAttrs, overrides));
},
statusMessage : function(overrides){
//intentionally doesn't have an author to mirror creation process, maybe we should change the creation process
return new app.models.StatusMessage(_.extend(factory.postAttrs(), overrides));
......
describe PostPresenter do
before do
@sm = FactoryGirl.create(:status_message, public: true)
@sm_with_poll = FactoryGirl.create(:status_message_with_poll, public: true)
@presenter = PostPresenter.new(@sm, bob)
@unauthenticated_presenter = PostPresenter.new(@sm)
end
let(:status_message) { FactoryGirl.create(:status_message, public: true) }
let(:status_message_with_poll) { FactoryGirl.create(:status_message_with_poll, public: true) }
let(:presenter) { PostPresenter.new(status_message, bob) }
let(:unauthenticated_presenter) { PostPresenter.new(status_message) }
it "takes a post and an optional user" do
expect(@presenter).not_to be_nil
expect(presenter).not_to be_nil
end
describe "#as_json" do
it "works with a user" do
expect(@presenter.as_json).to be_a Hash
expect(presenter.as_json).to be_a Hash
end
it "works without a user" do
expect(@unauthenticated_presenter.as_json).to be_a Hash
expect(unauthenticated_presenter.as_json).to be_a Hash
end
end
context "post with interactions" do
before do
bob.like!(status_message)
bob.reshare!(status_message)
end
describe "#with_interactions" do
it "works with a user" do
post_hash = presenter.with_interactions
expect(post_hash).to be_a Hash
expect(post_hash[:interactions]).to eq PostInteractionPresenter.new(status_message, bob).as_json
end
it "works without a user" do
post_hash = unauthenticated_presenter.with_interactions
expect(post_hash).to be_a Hash
expect(post_hash[:interactions]).to eq PostInteractionPresenter.new(status_message, nil).as_json
end
end
describe "#with_initial_interactions" do
it "works with a user" do
post_hash = presenter.with_initial_interactions
expect(post_hash).to be_a Hash
expect(post_hash[:interactions][:likes]).to eq(
LikeService.new(bob).find_for_post(status_message.id).as_api_response(:backbone)
)
expect(post_hash[:interactions][:reshares]).to eq(
ReshareService.new(bob).find_for_post(status_message.id).as_api_response(:backbone)
)
end
it "works without a user" do
post_hash = unauthenticated_presenter.with_initial_interactions
expect(post_hash).to be_a Hash
expect(post_hash[:interactions][:likes]).to eq(
LikeService.new.find_for_post(status_message.id).as_api_response(:backbone)
)
expect(post_hash[:interactions][:reshares]).to eq(
ReshareService.new.find_for_post(status_message.id).as_api_response(:backbone)
)
end
end
end
describe "#user_like" do
before do
bob.like!(status_message)
end
it "includes the users like" do
bob.like!(@sm)