diff --git a/app/controllers/blocks_controller.rb b/app/controllers/blocks_controller.rb new file mode 100644 index 0000000000..06521ac182 --- /dev/null +++ b/app/controllers/blocks_controller.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +class BlocksController < ApplicationController + load_and_authorize_resource + skip_load_resource only: :create + + def create + @block = current_member.blocks.build(blocked: Member.find(params[:blocked])) + + if @block.save + flash[:notice] = "Blocked #{@block.blocked.login_name}" + else + flash[:error] = "Already blocking or error while blocking." + end + redirect_back fallback_location: root_path + end + + def destroy + @block = current_member.blocks.find(params[:id]) + @unblocked = @block.blocked + @block.destroy + + flash[:notice] = "Unblocked #{@unblocked.login_name}" + redirect_to @unblocked + end + + private + + def block_params + params.permit(:id, :blocked) + end +end diff --git a/app/controllers/messages_controller.rb b/app/controllers/messages_controller.rb index bd34e3ce16..974c09d29d 100644 --- a/app/controllers/messages_controller.rb +++ b/app/controllers/messages_controller.rb @@ -27,10 +27,21 @@ def new def create if params[:conversation_id].present? @conversation = Mailboxer::Conversation.find(params[:conversation_id]) + # Check if any of the recipients have blocked the sender + if @conversation.recipients.any? { |recipient| recipient.already_blocking?(current_member) } + flash[:error] = "You cannot reply to this conversation because one of the recipients has blocked you." + redirect_to conversation_path(@conversation) + return + end current_member.reply_to_conversation(@conversation, params[:body]) redirect_to conversation_path(@conversation) else recipient = Member.find(params[:recipient_id]) + if recipient.already_blocking?(current_member) + flash[:error] = "You cannot send a message to a member who has blocked you." + redirect_back fallback_location: root_path + return + end body = params[:body] subject = params[:subject] @conversation = current_member.send_message(recipient, body, subject) diff --git a/app/models/block.rb b/app/models/block.rb new file mode 100644 index 0000000000..a149f75b19 --- /dev/null +++ b/app/models/block.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class Block < ApplicationRecord + belongs_to :blocker, class_name: "Member" + belongs_to :blocked, class_name: "Member" + + validates :blocker_id, uniqueness: { scope: :blocked_id } + + after_create :destroy_follow_relationship + + private + + def destroy_follow_relationship + # Destroy the follow relationship in both directions + Follow.where(follower: blocker, followed: blocked).destroy_all + Follow.where(follower: blocked, followed: blocker).destroy_all + end +end diff --git a/app/models/comment.rb b/app/models/comment.rb index a351b2fc1e..b6b80b2417 100644 --- a/app/models/comment.rb +++ b/app/models/comment.rb @@ -4,6 +4,7 @@ class Comment < ApplicationRecord belongs_to :author, class_name: 'Member', inverse_of: :comments belongs_to :commentable, polymorphic: true, counter_cache: true # validates :body, presence: true + validate :author_is_not_blocked scope :post_order, -> { order(created_at: :asc) } # for display on post page @@ -25,4 +26,13 @@ class Comment < ApplicationRecord def to_s "#{author.login_name} commented on #{commentable.subject}" end + + private + + def author_is_not_blocked + return unless author + if commentable.author.already_blocking?(author) + errors.add(:base, "You cannot comment on a post of a member who has blocked you.") + end + end end diff --git a/app/models/follow.rb b/app/models/follow.rb index 0b9c9236d4..e86c0dddd2 100644 --- a/app/models/follow.rb +++ b/app/models/follow.rb @@ -4,6 +4,7 @@ class Follow < ApplicationRecord belongs_to :follower, class_name: "Member", inverse_of: :follows belongs_to :followed, class_name: "Member", inverse_of: :inverse_follows validates :follower_id, uniqueness: { scope: :followed_id } + validate :follower_is_not_blocked after_create do Notification.create( @@ -14,4 +15,13 @@ class Follow < ApplicationRecord notifiable: self ) end + + private + + def follower_is_not_blocked + return unless follower + if followed.already_blocking?(follower) + errors.add(:base, "You cannot follow a member who has blocked you.") + end + end end diff --git a/app/models/like.rb b/app/models/like.rb index ed16065de8..493ed938c6 100644 --- a/app/models/like.rb +++ b/app/models/like.rb @@ -5,4 +5,23 @@ class Like < ApplicationRecord belongs_to :likeable, polymorphic: true, counter_cache: true, touch: true validates :member, :likeable, presence: true validates :member, uniqueness: { scope: :likeable } + validate :member_is_not_blocked + + def likeable_author + if likeable.respond_to?(:author) + likeable.author + elsif likeable.respond_to?(:owner) + likeable.owner + end + end + + private + + def member_is_not_blocked + return unless member + author = likeable_author + if author && author.already_blocking?(member) + errors.add(:base, "You cannot like content of a member who has blocked you.") + end + end end diff --git a/app/models/member.rb b/app/models/member.rb index 3bfcf9d7c4..32714d30e7 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -52,6 +52,15 @@ def regenerate_api_token has_many :followed, through: :follows has_many :followers, through: :inverse_follows, source: :follower + # + # Blocking other members + has_many :blocks, class_name: "Block", foreign_key: "blocker_id", dependent: :destroy, + inverse_of: :blocker + has_many :inverse_blocks, class_name: "Block", foreign_key: "blocked_id", + dependent: :destroy, inverse_of: :blocked + has_many :blocked_members, through: :blocks, source: :blocked + has_many :blockers, through: :inverse_blocks, source: :blocker + # # Global data records this member created has_many :requested_crops, class_name: 'Crop', foreign_key: 'requester_id', dependent: :nullify, @@ -179,4 +188,12 @@ def already_following?(member) def get_follow(member) follows.find_by(followed_id: member.id) if already_following?(member) end + + def already_blocking?(member) + blocks.exists?(blocked_id: member.id) + end + + def get_block(member) + blocks.find_by(blocked_id: member.id) if already_blocking?(member) + end end diff --git a/app/views/members/_follow_buttons.haml b/app/views/members/_follow_buttons.haml index 244dc15f41..d0e064d808 100644 --- a/app/views/members/_follow_buttons.haml +++ b/app/views/members/_follow_buttons.haml @@ -1,6 +1,11 @@ - if current_member && current_member != member # must be logged in, can't follow yourself + - block = current_member.get_block(member) - follow = current_member.get_follow(member) - - if !follow && can?(:create, Follow) # not already following + - if !block && !follow && can?(:create, Follow) # not already following, and not blocking = link_to 'Follow', follows_path(followed: member), method: :post, class: 'btn btn-block btn-success' - if follow && can?(:destroy, follow) # already following - = link_to 'Unfollow', follow_path(follow), method: :delete, class: 'btn btn-block' \ No newline at end of file + = link_to 'Unfollow', follow_path(follow), method: :delete, class: 'btn btn-block' + - if !block && can?(:create, Block) # not already blocking + = link_to 'Block', blocks_path(blocked: member), method: :post, class: 'btn btn-block btn-danger' + - if block && can?(:destroy, block) # already blocking + = link_to 'Unblock', block_path(block), method: :delete, class: 'btn btn-block' \ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index 0cb18fb3f0..170580fcac 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -96,6 +96,7 @@ resources :forums resources :follows, only: %i(create destroy) + resources :blocks, only: %i(create destroy) post 'likes' => 'likes#create' delete 'likes' => 'likes#destroy' @@ -112,6 +113,7 @@ resources :follows get 'followers' => 'follows#followers' + resources :blocks, only: %i(create destroy) end resources :messages diff --git a/db/migrate/20250901144900_create_blocks.rb b/db/migrate/20250901144900_create_blocks.rb new file mode 100644 index 0000000000..3c386d5aa5 --- /dev/null +++ b/db/migrate/20250901144900_create_blocks.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class CreateBlocks < ActiveRecord::Migration[6.1] + def change + create_table :blocks do |t| + t.references :blocker, foreign_key: { to_table: :members } + t.references :blocked, foreign_key: { to_table: :members } + + t.timestamps + end + add_index :blocks, [:blocker_id, :blocked_id], unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index b7722905e1..106694b322 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -85,6 +85,16 @@ t.index ["member_id"], name: "index_authentications_on_member_id" end + create_table "blocks", force: :cascade do |t| + t.bigint "blocker_id" + t.bigint "blocked_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["blocked_id"], name: "index_blocks_on_blocked_id" + t.index ["blocker_id", "blocked_id"], name: "index_blocks_on_blocker_id_and_blocked_id", unique: true + t.index ["blocker_id"], name: "index_blocks_on_blocker_id" + end + create_table "comfy_cms_categories", id: :serial, force: :cascade do |t| t.integer "site_id", null: false t.string "label", null: false @@ -659,6 +669,8 @@ add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" + add_foreign_key "blocks", "members", column: "blocked_id" + add_foreign_key "blocks", "members", column: "blocker_id" add_foreign_key "harvests", "plantings" add_foreign_key "mailboxer_conversation_opt_outs", "mailboxer_conversations", column: "conversation_id", name: "mb_opt_outs_on_conversations_id" add_foreign_key "mailboxer_notifications", "mailboxer_conversations", column: "conversation_id", name: "notifications_on_conversation_id" diff --git a/spec/features/members/blocking_spec.rb b/spec/features/members/blocking_spec.rb new file mode 100644 index 0000000000..81db5f3721 --- /dev/null +++ b/spec/features/members/blocking_spec.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe "blocks", :js do + context "when signed in" do + include_context 'signed in member' + let(:other_member) { create(:member) } + + it "your profile doesn't have a block button" do + visit member_path(member) + expect(page).to have_no_link "Block" + expect(page).to have_no_link "Unblock" + end + + context "blocking another member" do + before { visit member_path(other_member) } + + it "has a block button" do + expect(page).to have_link "Block", href: blocks_path(blocked: other_member.slug) + end + + it "has correct message and unblock button" do + click_link 'Block' + expect(page).to have_content "Blocked #{other_member.login_name}" + expect(page).to have_link "Unblock", href: block_path(member.get_block(other_member)) + end + + it "has correct message and block button after unblock" do + click_link 'Block' + click_link 'Unblock' + expect(page).to have_content "Unblocked #{other_member.login_name}" + visit member_path(other_member) # unblocking redirects to root + expect(page).to have_link "Block", href: blocks_path(blocked: other_member.slug) + end + + context "when a member is blocked" do + before do + click_link 'Block' + end + + it "prevents following" do + visit member_path(other_member) + expect(page).to have_no_link "Follow" + end + + it "prevents messaging" do + visit new_message_path(recipient_id: other_member.id) + fill_in "Subject", with: "Test message" + fill_in "Body", with: "Test message body" + click_button "Send message" + expect(page).to have_content "You cannot send a message to a member who has blocked you." + end + + it "prevents commenting" do + post = create(:post, author: other_member) + visit post_path(post) + fill_in "comment_body", with: "Test comment" + click_button "Post Comment" + expect(page).to have_content "You cannot comment on a post of a member who has blocked you." + end + + it "prevents liking" do + post = create(:post, author: other_member) + visit post_path(post) + click_link "Like" + expect(page).to have_content "You cannot like content of a member who has blocked you." + end + end + end + end +end diff --git a/spec/features/members/following_spec.rb b/spec/features/members/following_spec.rb index 4c2f51116f..e45928d9a0 100644 --- a/spec/features/members/following_spec.rb +++ b/spec/features/members/following_spec.rb @@ -23,6 +23,17 @@ expect(page).to have_no_link "Unfollow" end + context "when the other member is blocked" do + before do + member.blocks.create(blocked: other_member) + visit member_path(other_member) + end + + it "does not have a follow button" do + expect(page).to have_no_link "Follow" + end + end + context "following another member" do before { visit member_path(other_member) } diff --git a/spec/models/comment_spec.rb b/spec/models/comment_spec.rb index 4d627c1696..bccc0198e6 100644 --- a/spec/models/comment_spec.rb +++ b/spec/models/comment_spec.rb @@ -41,6 +41,21 @@ end end + context "when the post author has blocked the comment author" do + let(:post_author) { create(:member) } + let(:comment_author) { create(:member) } + let(:post) { create(:post, author: post_author) } + + before do + post_author.blocks.create(blocked: comment_author) + end + + it "is not valid" do + comment = build(:comment, commentable: post, author: comment_author) + expect(comment).not_to be_valid + end + end + context "ordering" do before do @m = FactoryBot.create(:member) diff --git a/spec/models/like_spec.rb b/spec/models/like_spec.rb index 13f51412b9..5ec8bef4cf 100644 --- a/spec/models/like_spec.rb +++ b/spec/models/like_spec.rb @@ -63,6 +63,21 @@ expect(Like.all).not_to include like end + context "when the likeable author has blocked the member" do + let(:likeable_author) { create(:member) } + let(:post_author) { create(:member) } + let(:post) { create(:post, author: likeable_author) } + + before do + likeable_author.blocks.create(blocked: member) + end + + it "is not valid" do + like = build(:like, likeable: post, member: member) + expect(like).not_to be_valid + end + end + it 'liked_by_members_names' do expect(post.liked_by_members_names).to eq [] Like.create(member:, likeable: post)