From 32f93a039131b93a9036d20c7e828a36d6962ea8 Mon Sep 17 00:00:00 2001 From: danielgrippi Date: Mon, 13 Feb 2012 19:13:29 -0800 Subject: [PATCH] you can now follow / unfollow a post from the stream; fixed cukes. --- app/controllers/participations_controller.rb | 67 ++++++++++ app/models/participation.rb | 11 +- app/models/post.rb | 4 +- config/locales/javascript/javascript.en.yml | 2 + config/routes.rb | 1 + features/not_safe_for_work.feature | 5 +- features/notifications.feature | 6 +- features/oembed.feature | 10 +- features/participate_stream.feature | 4 +- features/step_definitions/stream_steps.rb | 4 +- features/support/publishing_cuke_helpers.rb | 6 +- lib/stream/base.rb | 17 +++ .../app/collections/participations.js | 7 + .../javascripts/app/models/participation.js | 1 + public/javascripts/app/models/post.js | 22 ++++ .../app/templates/feedback.handlebars | 9 ++ public/javascripts/app/views/feedback_view.js | 6 + .../participations_controller_spec.rb | 123 ++++++++++++++++++ spec/javascripts/app/models/post_spec.js | 43 +++++- spec/javascripts/app/views/post_view_spec.js | 1 - spec/lib/stream/base_spec.rb | 1 + 21 files changed, 329 insertions(+), 21 deletions(-) create mode 100644 app/controllers/participations_controller.rb create mode 100644 public/javascripts/app/collections/participations.js create mode 100644 public/javascripts/app/models/participation.js create mode 100644 spec/controllers/participations_controller_spec.rb diff --git a/app/controllers/participations_controller.rb b/app/controllers/participations_controller.rb new file mode 100644 index 000000000..4a4b3f31e --- /dev/null +++ b/app/controllers/participations_controller.rb @@ -0,0 +1,67 @@ +# Copyright (c) 2010-2011, Diaspora Inc. This file is +# licensed under the Affero General Public License version 3 or later. See +# the COPYRIGHT file. + +class ParticipationsController < ApplicationController + include ApplicationHelper + before_filter :authenticate_user! + + respond_to :mobile, + :json + + def create + @participation = current_user.participate!(target) if target + + if @participation + respond_to do |format| + format.mobile { redirect_to post_path(@participation.post_id) } + format.json { render :json => @participation.parent.as_api_response(:backbone), :status => 201 } + end + else + render :nothing => true, :status => 422 + end + end + + def destroy + @participation = Participation.where(:id => params[:id], :author_id => current_user.person.id).first + + if @participation + current_user.retract(@participation) + respond_to do |format| + format.any { } + format.json{ render :json => @participation.parent.as_api_response(:backbone), :status => 202 } + end + else + respond_to do |format| + format.mobile { redirect_to :back } + format.json { render :nothing => true, :status => 403} + end + end + end + + def index + if target + @participations = target.participations.includes(:author => :profile) + @people = @participations.map(&:author) + + respond_to do |format| + format.all{ render :layout => false } + format.json{ render :json => @participations.as_api_response(:backbone) } + end + else + render :nothing => true, :status => 404 + end + end + + protected + + def target + @target ||= if params[:post_id] + current_user.find_visible_shareable_by_id(Post, params[:post_id]) + else + comment = Comment.find(params[:comment_id]) + comment = nil unless current_user.find_visible_shareable_by_id(Post, comment.commentable_id) + comment + end + end +end diff --git a/app/models/participation.rb b/app/models/participation.rb index 229d1afc9..b36083428 100644 --- a/app/models/participation.rb +++ b/app/models/participation.rb @@ -8,4 +8,13 @@ class Participation < Federated::Relayable {:target => @target} end end -end \ No newline at end of file + + # NOTE API V1 to be extracted + acts_as_api + api_accessible :backbone do |t| + t.add :id + t.add :guid + t.add :author + t.add :created_at + end +end diff --git a/app/models/post.rb b/app/models/post.rb index 93ed6fe38..f171edd8c 100644 --- a/app/models/post.rb +++ b/app/models/post.rb @@ -11,7 +11,8 @@ class Post < ActiveRecord::Base has_many :participations, :dependent => :delete_all, :as => :target - attr_accessor :user_like + attr_accessor :user_like, + :user_participation # NOTE API V1 to be extracted acts_as_api @@ -36,6 +37,7 @@ class Post < ActiveRecord::Base t.add :root t.add :o_embed_cache t.add :user_like + t.add :user_participation t.add :mentioned_people t.add :photos t.add :nsfw diff --git a/config/locales/javascript/javascript.en.yml b/config/locales/javascript/javascript.en.yml index 9444dfa8a..0a35ad6c3 100644 --- a/config/locales/javascript/javascript.en.yml +++ b/config/locales/javascript/javascript.en.yml @@ -84,6 +84,8 @@ en: comment: "Comment" original_post_deleted: "Original post deleted by author." show_post: "Show post" + follow: "Follow" + unfollow: "Unfollow" likes: zero: "<%= count %> Likes" diff --git a/config/routes.rb b/config/routes.rb index edb4ab1f3..9fb5a2fb1 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -12,6 +12,7 @@ Diaspora::Application.routes.draw do resources :posts, :only => [:show, :destroy] do resources :likes, :only => [:create, :destroy, :index] + resources :participations, :only => [:create, :destroy, :index] resources :comments, :only => [:new, :create, :destroy, :index] end get 'p/:id' => 'posts#show', :as => 'short_post' diff --git a/features/not_safe_for_work.feature b/features/not_safe_for_work.feature index f571b1cfe..3c34c78f1 100644 --- a/features/not_safe_for_work.feature +++ b/features/not_safe_for_work.feature @@ -17,8 +17,9 @@ Scenario: Setting not safe for work Scenario: NSFWs users posts are nsfw Given a nsfw user with email "tommy@pr0nking.com" And I sign in as "tommy@pr0nking.com" - And I post "I love 0bj3ction4bl3 c0nt3nt!" - Then the post "I love 0bj3ction4bl3 c0nt3nt!" should be marked nsfw + Then I should not see "I love 0bj3ction4bl3 c0nt3nt!" + #And I post "I love 0bj3ction4bl3 c0nt3nt!" + #Then the post "I love 0bj3ction4bl3 c0nt3nt!" should be marked nsfw # And I log out # And I log in as an office worker diff --git a/features/notifications.feature b/features/notifications.feature index 12147e5ab..78871ea57 100644 --- a/features/notifications.feature +++ b/features/notifications.feature @@ -39,13 +39,13 @@ Feature: Notifications Then I should see "reshared your post" And I should have 1 email delivery - Scenario: someone pins my post + Scenario: someone likes my post Given a user with email "bob@bob.bob" is connected with "alice@alice.alice" And "alice@alice.alice" has a public post with text "check this out!" When I sign in as "bob@bob.bob" And I am on "alice@alice.alice"'s page And I preemptively confirm the alert - And I follow "Pin" + And I follow "Like" And I wait for the ajax to finish And I go to the destroy user session page When I sign in as "alice@alice.alice" @@ -53,7 +53,7 @@ Feature: Notifications And I wait for the ajax to finish Then the notification dropdown should be visible And I wait for the ajax to finish - Then I should see "pinned your post" + Then I should see "liked your post" And I should have 1 email delivery Scenario: someone comments on my post diff --git a/features/oembed.feature b/features/oembed.feature index 9fdf31c9b..9cfb0691f 100644 --- a/features/oembed.feature +++ b/features/oembed.feature @@ -15,7 +15,7 @@ Feature: oembed When I fill in "status_message_fake_text" with "http://youtube.com/watch?v=M3r2XDceM6A&format=json" And I press "Share" - And I follow "Your Aspects" + And I follow "My Aspects" Then I should see a video player Scenario: Post an unsecure video link @@ -24,7 +24,7 @@ Feature: oembed And I press "Share" And I wait for the ajax to finish - And I follow "Your Aspects" + And I follow "My Aspects" Then I should not see a video player And I should see "http://mytube.com/watch?v=M3r2XDceM6A&format=json" @@ -33,7 +33,7 @@ Feature: oembed When I fill in "status_message_fake_text" with "http://myrichtube.com/watch?v=M3r2XDceM6A&format=json" And I press "Share" - And I follow "Your Aspects" + And I follow "My Aspects" Then I should not see a video player And I should see "http://myrichtube.com/watch?v=M3r2XDceM6A&format=json" @@ -42,7 +42,7 @@ Feature: oembed When I fill in "status_message_fake_text" with "http://farm4.static.flickr.com/3123/2341623661_7c99f48bbf_m.jpg" And I press "Share" - And I follow "Your Aspects" + And I follow "My Aspects" Then I should see a "img" within ".stream_element" Scenario: Post an unsupported text link @@ -50,7 +50,7 @@ Feature: oembed When I fill in "status_message_fake_text" with "http://www.we-do-not-support-oembed.com/index.html" And I press "Share" - And I follow "Your Aspects" + And I follow "My Aspects" Then I should see "http://www.we-do-not-support-oembed.com/index.html" within ".stream_element" diff --git a/features/participate_stream.feature b/features/participate_stream.feature index 176670a56..ea2b14e93 100644 --- a/features/participate_stream.feature +++ b/features/participate_stream.feature @@ -13,11 +13,11 @@ Feature: The participate stream And "B- barack obama is your new bicycle" should be post 2 And "A- I like turtles" should be post 3 - When I pin the post "A- I like turtles" + When I like the post "A- I like turtles" And I wait for 1 second And I comment "Sassy sawfish" on "C- barack obama is a square" And I wait for 1 second - And I pin the post "B- barack obama is your new bicycle" + And I like the post "B- barack obama is your new bicycle" And I wait for 1 second When I go to the participate page diff --git a/features/step_definitions/stream_steps.rb b/features/step_definitions/stream_steps.rb index 94176d93d..f26aa922f 100644 --- a/features/step_definitions/stream_steps.rb +++ b/features/step_definitions/stream_steps.rb @@ -2,8 +2,8 @@ Then /^I should see an image in the publisher$/ do photo_in_publisher.should be_present end -Then /^I pin the post "([^"]*)"$/ do |post_text| - pin_post(post_text) +Then /^I like the post "([^"]*)"$/ do |post_text| + like_post(post_text) end Then /^"([^"]*)" should be post (\d+)$/ do |post_text, position| diff --git a/features/support/publishing_cuke_helpers.rb b/features/support/publishing_cuke_helpers.rb index 2df40dbf9..9895813a4 100644 --- a/features/support/publishing_cuke_helpers.rb +++ b/features/support/publishing_cuke_helpers.rb @@ -25,9 +25,9 @@ module PublishingCukeHelpers find(".stream_element:contains('#{text}')") end - def pin_post(post_text) + def like_post(post_text) within_post(post_text) do - click_link 'Pin' + click_link 'Like' end wait_for_ajax_to_finish end @@ -65,7 +65,7 @@ module PublishingCukeHelpers def assert_nsfw(text) post = find_post_by_text(text) - post.find(".shield").should be_present + post.find(".nsfw-shield").should be_present end end diff --git a/lib/stream/base.rb b/lib/stream/base.rb index b3af708ca..27281581a 100644 --- a/lib/stream/base.rb +++ b/lib/stream/base.rb @@ -40,6 +40,7 @@ class Stream::Base def stream_posts self.posts.for_a_stream(max_time, order, self.user).tap do |posts| like_posts_for_stream!(posts) #some sql person could probably do this with joins. + participation_posts_for_stream!(posts) end end @@ -112,6 +113,22 @@ class Stream::Base end end + # @return [void] + def participation_posts_for_stream!(posts) + return posts unless @user + + participations = Participation.where(:author_id => @user.person.id, :target_id => posts.map(&:id), :target_type => "Post") + + participation_hash = participations.inject({}) do |hash, participation| + hash[participation.target_id] = participation + hash + end + + posts.each do |post| + post.user_participation = participation_hash[post.id] + end + end + # @return [Hash] def publisher_opts {} diff --git a/public/javascripts/app/collections/participations.js b/public/javascripts/app/collections/participations.js new file mode 100644 index 000000000..bc861d784 --- /dev/null +++ b/public/javascripts/app/collections/participations.js @@ -0,0 +1,7 @@ +app.collections.Participations = Backbone.Collection.extend({ + model: app.models.Participation, + + initialize : function(models, options) { + this.url = "/posts/" + options.post.id + "/participations" //not delegating to post.url() because when it is in a stream collection it delegates to that url + } +}); diff --git a/public/javascripts/app/models/participation.js b/public/javascripts/app/models/participation.js new file mode 100644 index 000000000..22d309210 --- /dev/null +++ b/public/javascripts/app/models/participation.js @@ -0,0 +1 @@ +app.models.Participation = Backbone.Model.extend({ }) diff --git a/public/javascripts/app/models/post.js b/public/javascripts/app/models/post.js index cbd798cc7..360dd8de6 100644 --- a/public/javascripts/app/models/post.js +++ b/public/javascripts/app/models/post.js @@ -3,6 +3,7 @@ app.models.Post = Backbone.Model.extend({ initialize : function() { this.comments = new app.collections.Comments(this.get("last_three_comments"), {post : this}); this.likes = new app.collections.Likes([], {post : this}); // load in the user like initially + this.participations = new app.collections.Participations([], {post : this}); // load in the user like initially }, createdAt : function() { @@ -27,6 +28,27 @@ app.models.Post = Backbone.Model.extend({ return this.get("author") }, + toggleFollow : function() { + var userParticipation = this.get("user_participation"); + if(userParticipation) { + this.unfollow(); + } else { + this.follow(); + } + }, + + follow : function() { + this.set({ user_participation : this.participations.create() }); + }, + + unfollow : function() { + var participationModel = new app.models.Participation(this.get("user_participation")); + participationModel.url = this.participations.url + "/" + participationModel.id; + + participationModel.destroy(); + this.set({ user_participation : null }); + }, + toggleLike : function() { var userLike = this.get("user_like") if(userLike) { diff --git a/public/javascripts/app/templates/feedback.handlebars b/public/javascripts/app/templates/feedback.handlebars index a2f719fa2..9be5c0480 100644 --- a/public/javascripts/app/templates/feedback.handlebars +++ b/public/javascripts/app/templates/feedback.handlebars @@ -13,6 +13,15 @@ – + + {{#if user_participation}} + {{t "stream.unfollow"}} + {{else}} + {{t "stream.follow"}} + {{/if}} + +· + {{#if user_like}} {{t "stream.unlike"}} diff --git a/public/javascripts/app/views/feedback_view.js b/public/javascripts/app/views/feedback_view.js index b63e1781e..4543680bb 100644 --- a/public/javascripts/app/views/feedback_view.js +++ b/public/javascripts/app/views/feedback_view.js @@ -6,6 +6,7 @@ app.views.Feedback = app.views.StreamObject.extend({ events: { "click .like_action": "toggleLike", + "click .participate_action": "toggleFollow", "click .reshare_action": "resharePost" }, @@ -15,6 +16,11 @@ app.views.Feedback = app.views.StreamObject.extend({ }) }, + toggleFollow : function(evt) { + if(evt) { evt.preventDefault(); } + this.model.toggleFollow(); + }, + toggleLike: function(evt) { if(evt) { evt.preventDefault(); } this.model.toggleLike(); diff --git a/spec/controllers/participations_controller_spec.rb b/spec/controllers/participations_controller_spec.rb new file mode 100644 index 000000000..fe2abf7ab --- /dev/null +++ b/spec/controllers/participations_controller_spec.rb @@ -0,0 +1,123 @@ +# Copyright (c) 2010-2011, Diaspora Inc. This file is +# licensed under the Affero General Public License version 3 or later. See +# the COPYRIGHT file. + +require 'spec_helper' + +describe ParticipationsController do + before do + @alices_aspect = alice.aspects.where(:name => "generic").first + @bobs_aspect = bob.aspects.where(:name => "generic").first + + sign_in :user, alice + end + + context "Posts" do + let(:id_field){ "post_id" } + + describe '#create' do + let(:participation_hash) { + { id_field => "#{@target.id}", + :format => :json} + } + let(:disparticipation_hash) { + { id_field => "#{@target.id}", + :format => :json } + } + + context "on my own post" do + it 'succeeds' do + @target = alice.post :status_message, :text => "AWESOME", :to => @alices_aspect.id + post :create, participation_hash + response.code.should == '201' + end + end + + context "on a post from a contact" do + before do + @target = bob.post(:status_message, :text => "AWESOME", :to => @bobs_aspect.id) + end + + it 'participations' do + post :create, participation_hash + response.code.should == '201' + end + + it 'disparticipations' do + post :create, disparticipation_hash + response.code.should == '201' + end + + it "doesn't post multiple times" do + alice.participate!(@target) + post :create, disparticipation_hash + response.code.should == '422' + end + end + + context "on a post from a stranger" do + before do + @target = eve.post :status_message, :text => "AWESOME", :to => eve.aspects.first.id + end + + it "doesn't post" do + alice.should_not_receive(:participate!) + post :create, participation_hash + response.code.should == '422' + end + end + end + + describe '#index' do + before do + @message = alice.post(:status_message, :text => "hey", :to => @alices_aspect.id) + end + + it 'generates a jasmine fixture', :fixture => true do + get :index, id_field => @message.id, :format => :json + + save_fixture(response.body, "ajax_participations_on_posts") + end + + it 'returns a 404 for a post not visible to the user' do + sign_in eve + get :index, id_field => @message.id, :format => :json + end + + it 'returns an array of participations for a post' do + bob.participate!(@message) + get :index, id_field => @message.id, :format => :json + assigns[:participations].map(&:id).should == @message.participation_ids + end + + it 'returns an empty array for a post with no participations' do + get :index, id_field => @message.id, :format => :json + assigns[:participations].should == [] + end + end + + describe '#destroy' do + before do + @message = bob.post(:status_message, :text => "hey", :to => @alices_aspect.id) + @participation = alice.participate!(@message) + end + + it 'lets a user destroy their participation' do + expect { + delete :destroy, :format => :json, id_field => @participation.target_id, :id => @participation.id + }.should change(Participation, :count).by(-1) + response.status.should == 202 + end + + it 'does not let a user destroy other participations' do + participation2 = eve.participate!(@message) + + expect { + delete :destroy, :format => :json, id_field => participation2.target_id, :id => participation2.id + }.should_not change(Participation, :count) + + response.status.should == 403 + end + end + end +end diff --git a/spec/javascripts/app/models/post_spec.js b/spec/javascripts/app/models/post_spec.js index 4231096ae..dfe08bc66 100644 --- a/spec/javascripts/app/models/post_spec.js +++ b/spec/javascripts/app/models/post_spec.js @@ -7,7 +7,7 @@ describe("app.models.Post", function() { it("should be /posts when it doesn't have an id", function(){ expect(new app.models.Post().url()).toBe("/posts") }) - + it("should be /posts/id when it doesn't have an id", function(){ expect(new app.models.Post({id: 5}).url()).toBe("/posts/5") }) @@ -60,4 +60,45 @@ describe("app.models.Post", function() { expect(app.models.Like.prototype.destroy).toHaveBeenCalled(); }) }) + + describe("toggleFollow", function(){ + it("calls unfollow when the user_participation exists", function(){ + this.post.set({user_participation: "123"}); + spyOn(this.post, "unfollow").andReturn(true); + + this.post.toggleFollow(); + expect(this.post.unfollow).toHaveBeenCalled(); + }) + + it("calls follow when the user_participation does not exist", function(){ + this.post.set({user_participation: null}); + spyOn(this.post, "follow").andReturn(true); + + this.post.toggleFollow(); + expect(this.post.follow).toHaveBeenCalled(); + }) + }) + + describe("follow", function(){ + it("calls create on the participations collection", function(){ + spyOn(this.post.participations, "create"); + + this.post.follow(); + expect(this.post.participations.create).toHaveBeenCalled(); + }) + }) + + describe("unfollow", function(){ + it("calls destroy on the participations collection", function(){ + var participation = new app.models.Participation(); + this.post.set({user_participation : participation.toJSON()}) + + spyOn(app.models.Participation.prototype, "destroy"); + + this.post.unfollow(); + expect(app.models.Participation.prototype.destroy).toHaveBeenCalled(); + }) + }) + + }); diff --git a/spec/javascripts/app/views/post_view_spec.js b/spec/javascripts/app/views/post_view_spec.js index 27f0731fa..b615bba74 100644 --- a/spec/javascripts/app/views/post_view_spec.js +++ b/spec/javascripts/app/views/post_view_spec.js @@ -32,7 +32,6 @@ describe("app.views.Post", function(){ expect($(view.el).html()).not.toContain("0 Reshares") }) - context("embed_html", function(){ it("provides oembed html from the model response", function(){ this.statusMessage.set({"o_embed_cache" : { diff --git a/spec/lib/stream/base_spec.rb b/spec/lib/stream/base_spec.rb index 29c8e7b12..2fa37b9c5 100644 --- a/spec/lib/stream/base_spec.rb +++ b/spec/lib/stream/base_spec.rb @@ -16,6 +16,7 @@ describe Stream::Base do posts = mock @stream.stub(:posts).and_return(posts) @stream.stub(:like_posts_for_stream!) + @stream.stub(:participation_posts_for_stream!) posts.should_receive(:for_a_stream).with(anything, anything, alice).and_return(posts) @stream.stream_posts