Mute notifications for a comment thread (#1636)

* Use idempotent update instead of toggle

* Update editor guide with new comment instructions

* Render the settings button on page load

* Add settings button to comment dropdown

* Update styles to properly render

* Add comment mute function and settings page

* Clean up erb spacing

* Remove unneccessary user_id param

* Add receive_notifications for policy spec

* Update name for accuracy

* Add request spec for comments_mute

* Remove unused permitted attributes

* Add actually used permitted attributes
This commit is contained in:
Andy Zhao 2019-01-24 17:20:38 -05:00 committed by Ben Halpern
parent 9d72091f42
commit c4677071a7
16 changed files with 166 additions and 22 deletions

View file

@ -57,12 +57,24 @@ function addRelevantButtonsToArticle(user) {
}
}
var commentsContainer = document.getElementById('comments-container');
if (commentsContainer && user.trusted) {
var modButts = document.getElementsByClassName('mod-actions');
for (var i = 0; i < modButts.length; i++) {
var butt = modButts[i];
butt.className = 'mod-actions';
butt.style.display = 'inline-block';
if (commentsContainer) {
var settingsButts = document.getElementsByClassName('comment-actions');
for (var i = 0; i < settingsButts.length; i++) {
var butt = settingsButts[i];
if (parseInt(butt.dataset.userId) === user.id) {
butt.className = 'comment-actions';
butt.style.display = 'inline-block';
}
}
if (user.trusted) {
var modButts = document.getElementsByClassName('mod-actions');
for (var i = 0; i < modButts.length; i++) {
var butt = modButts[i];
butt.className = 'mod-actions';
butt.style.display = 'inline-block';
}
}
}
}

View file

@ -652,7 +652,6 @@ a.header-link{
top: 40px;
right: 10px;
display: inline-block;
white-space: nowrap;
.dropdown-content {
display: none;
@ -662,6 +661,7 @@ a.header-link{
border-radius: 3px;
background: white;
z-index:20;
width: 130px;
box-shadow: $shadow;
&.showing{
display: block;
@ -671,11 +671,12 @@ a.header-link{
.dropdown-content a {
color: black;
padding: 12px 16px;
width: 93px;
width: 98px;
height: 14px;
font-weight: bold;
display: block;
font-size: 14px;
white-space: nowrap;
}
.dropdown-content a:hover { background-color: #f1f1f1 }

View file

@ -2,9 +2,9 @@ class ArticleMutesController < ApplicationController
after_action :verify_authorized
def update
@article = Article.find_by(id: params[:id], user_id: current_user.id)
@article = Article.find_by(id: params[:id])
authorize @article
@article.update(receive_notifications: !@article.receive_notifications)
@article.update(receive_notifications: permitted_attributes(@article)[:receive_notifications])
redirect_to "/dashboard"
end
end

View file

@ -0,0 +1,11 @@
class CommentMutesController < ApplicationController
after_action :verify_authorized
def update
@comment = Comment.find_by(id: params[:id])
authorize @comment
related_comments_ids = @comment.subtree.union(@comment.ancestors).where(user_id: @comment.user_id).pluck(:id)
Comment.where(id: related_comments_ids).update_all(receive_notifications: permitted_attributes(@comment)[:receive_notifications])
redirect_to "#{@comment.path}/settings"
end
end

View file

@ -148,6 +148,12 @@ class CommentsController < ApplicationController
end
end
def settings
@comment = Comment.find(params[:id_code].to_i(26))
authorize @comment
render :settings
end
private
# Use callbacks to share common setup or constraints between actions.

View file

@ -30,7 +30,7 @@ class ArticlePolicy < ApplicationPolicy
def permitted_attributes
%i[title body_html body_markdown main_image published canonical_url
description allow_small_edits allow_big_edits tag_list publish_under_org
video video_code video_source_url video_thumbnail_url]
video video_code video_source_url video_thumbnail_url receive_notifications]
end
private

View file

@ -19,12 +19,16 @@ class CommentPolicy < ApplicationPolicy
edit?
end
def settings?
edit?
end
def preview?
true
end
def permitted_attributes_for_update
%i[body_markdown]
%i[body_markdown receive_notifications]
end
def permitted_attributes_for_preview

View file

@ -55,8 +55,11 @@
<a href="<%= comment.path %>">
Permalink
</a>
<span class="mod-actions hidden" style="display:none">
<a href="<%=comment.path%>/mod" class="edit-butt" rel="nofollow" style="color:#0a0a0a">Moderate</a>
<span class="comment-actions hidden" data-user-id="<%= comment.user_id %>" style="display: none;">
<a href="<%= comment.path %>/settings" rel="nofollow" style="color:#0a0a0a;" data-no-instant>Settings</a>
</span>
<span class="mod-actions hidden" style="display: none;">
<a href="<%=comment.path%>/mod" rel="nofollow" style="color:#0a0a0a">Moderate</a>
</span>
<a href="/report-abuse?url=https://dev.to<%= comment.path %>">Report Abuse</a>
</div>
@ -69,7 +72,7 @@
<img class="voted-heart" src="<%= asset_path("emoji/emoji-one-heart.png") %>"/>
</button>
</div>
<div class="actions" data-comment-id="<%= comment.id %>" data-path="<%=commentable.path%>/comments/<%= comment.id_code_generated %>">
<div class="actions" data-comment-id="<%= comment.id %>" data-path="<%= commentable.path %>/comments/<%= comment.id_code_generated %>">
<span class="current-user-actions hidden" style="display:none">
<a data-no-instant href="<%=commentable.path%>/comments/<%= comment.id_code_generated %>/delete_confirm" class="edit-butt" rel="nofollow">DELETE</a>
<a href="<%=commentable.path%>/comments/<%= comment.id_code_generated %>/edit" class="edit-butt" rel="nofollow">EDIT</a>

View file

@ -0,0 +1,38 @@
<style>
.pill{
padding:4px 20px;
border-radius:100px;
color:white;
margin-right:5px;
font-size:14px;
border: 2px solid transparent;
margin-top: 6px;
display: inline-block;
}
.mute{
background-color: #EE7453;
color: #ffffff;
}
.unmute{
/* inverted background color */
background-color: #1395b8;
color: #ffffff;
}
.container{
text-align: center;
}
</style>
<div class="container">
<h2>Settings for: <a href="<%= @comment.path %>"><%= @comment.title %></a></h2>
<% if @comment.receive_notifications %>
<p>This will mute all notifications for this comment and any of your comments in the thread.</p>
<% else %>
<p>All notifications for this comment and any of your comments in the thread are currently muted.</p>
<% end %>
<%= form_for(@comment, url: "/comment_mutes/#{@comment.id}", method: "patch", html: { class: "mute-form" }) do |f| %>
<%= f.hidden_field :receive_notifications, value: !@comment.receive_notifications %>
<%= f.submit @comment.receive_notifications ? "MUTE NOTIFICATIONS" : "NOTIFICATIONS MUTED", class: "cta pill #{@comment.receive_notifications ? "mute" : "unmute"}" %>
<% end %>
<br>
</div>

View file

@ -23,6 +23,7 @@
<a href="<%= article.path %>/edit" class="pill cta green">EDIT</a>
<a href="<%= article.path %>/delete_confirm" data-no-instant class="cta pill black">DELETE</a>
<%= form_for(article, url: "/article_mutes/#{article.id}", method: "patch", html: { class: "mute-form" }) do |f| %>
<%= f.hidden_field :receive_notifications, value: !article.receive_notifications %>
<%= f.submit article.receive_notifications ? "MUTE NOTIFICATIONS" : "NOTIFICATIONS MUTED", class: "cta pill #{article.receive_notifications ? "mute" : "unmute"}" %>
<% end %>
<% if article.published %>

View file

@ -54,7 +54,7 @@
<code>{% tag git %}</code>
<h3><strong>dev.to Comment Embed</strong></h3>
<p>All you need is the <code>ID</code> at the end of a comment URL. To get the comment link, click the top right arrow on a comment and then click "Permalink". Here's an example:</p>
<p>All you need is the <code>ID</code> at the end of a comment URL. To get the comment link, click either the timestamp or the menu button in the top right corner on a comment and then click "Permalink". Here's an example:</p>
<code>{% devcomment 2d1a %}</code>
<h3><strong>dev.to Podcast Episode Embed</strong></h3>

View file

@ -97,6 +97,7 @@ Rails.application.routes.draw do
resources :articles, only: %i[update create destroy]
resources :article_mutes, only: %i[update]
resources :comments, only: %i[create update destroy]
resources :comment_mutes, only: %i[update]
resources :users, only: [:update]
resources :reactions, only: %i[index create]
resources :feedback_messages, only: %i[index create]
@ -291,6 +292,7 @@ Rails.application.routes.draw do
get "/:username/comment/:id_code/edit" => "comments#edit"
get "/:username/comment/:id_code/delete_confirm" => "comments#delete_confirm"
get "/:username/comment/:id_code/mod" => "moderations#comment"
get "/:username/comment/:id_code/settings", to: "comments#settings"
get "/:username/:slug/:view" => "stories#show",
constraints: { view: /moderate/ }

View file

@ -7,7 +7,7 @@ RSpec.describe ArticlePolicy do
let(:valid_attributes) do
%i[title body_html body_markdown main_image published
description allow_small_edits allow_big_edits tag_list publish_under_org
video video_code video_source_url video_thumbnail_url]
video video_code video_source_url video_thumbnail_url receive_notifications]
end
context "when user is not signed-in" do

View file

@ -10,7 +10,7 @@ RSpec.describe CommentPolicy do
end
let(:valid_attributes_for_update) do
%i[body_markdown]
%i[body_markdown receive_notifications]
end
context "when user is not signed-in" do
@ -25,9 +25,7 @@ RSpec.describe CommentPolicy do
it { is_expected.to permit_actions(%i[create]) }
it { is_expected.to forbid_actions(%i[edit update destroy delete_confirm]) }
it do
expect(comment_policy).to permit_mass_assignment_of(valid_attributes_for_create).for_action(:create)
end
it { is_expected.to permit_mass_assignment_of(valid_attributes_for_create).for_action(:create) }
context "with banned status" do
before { user.add_role :banned }

View file

@ -0,0 +1,68 @@
require "rails_helper"
RSpec.describe "CommentMutes", type: :request do
let(:original_commenter) { create(:user) }
let(:other_commenter) { create(:user) }
let(:article) { create(:article) }
let(:parent_comment_by_og) { create(:comment, commentable: article, user: original_commenter) }
let(:child_of_parent_by_other) { create(:comment, commentable: article, user: other_commenter, ancestry: parent_comment_by_og.id.to_s) }
let(:child_of_child_by_og) { create(:comment, commentable: article, user: original_commenter, ancestry: "#{parent_comment_by_og.id}/#{child_of_parent_by_other.id}") }
let(:child_of_child_of_child_by_other) { create(:comment, commentable: article, user: other_commenter, ancestry: "#{parent_comment_by_og.id}/#{child_of_parent_by_other.id}/#{child_of_child_by_og.id}") }
let(:child_of_child_of_child_by_og) { create(:comment, commentable: article, user: original_commenter, ancestry: "#{parent_comment_by_og.id}/#{child_of_parent_by_other.id}/#{child_of_child_by_og.id}/#{child_of_child_by_other.id}") }
let(:child_of_child_by_other) { create(:comment, commentable: article, user: other_commenter, ancestry: "#{parent_comment_by_og.id}/#{child_of_parent_by_other.id}") }
let(:child2_of_child_of_child_by_og) { create(:comment, commentable: article, user: original_commenter, ancestry: "#{parent_comment_by_og.id}/#{child_of_parent_by_other.id}/#{child_of_child_by_other.id}") }
let(:parent_comment_by_other) { create(:comment, commentable: article, user: other_commenter) }
describe "PATCH /comment_mutes/:id" do
context "when an article has two parent comments by two different people" do
before do
parent_comment_by_og
parent_comment_by_other
end
it "mutes the parent comment" do
sign_in original_commenter
patch "/comment_mutes/#{parent_comment_by_og.id}", params: { comment: { receive_notifications: "false" } }
expect(parent_comment_by_og.reload.receive_notifications).to be false
end
it "does not mute the someone else's parent comment" do
sign_in original_commenter
patch "/comment_mutes/#{parent_comment_by_og.id}", params: { comment: { receive_notifications: "false" } }
expect(parent_comment_by_other.reload.receive_notifications).to be true
end
it "unmutes the parent comment if already muted" do
sign_in original_commenter
parent_comment_by_og.update(receive_notifications: false)
patch "/comment_mutes/#{parent_comment_by_og.id}", params: { comment: { receive_notifications: "true" } }
expect(parent_comment_by_og.reload.receive_notifications).to eq true
end
end
context "when an article has a single comment thread with multiple commenters" do
before do
child_of_child_of_child_by_og
child_of_child_of_child_by_other
child2_of_child_of_child_by_og
parent_comment_by_other
sign_in original_commenter
patch "/comment_mutes/#{parent_comment_by_og.id}", params: { comment: { receive_notifications: "false" } }
end
it "mutes all of the original commenter's comments in a single thread" do
user_ids_of_muted_comments = Comment.where(receive_notifications: false).pluck(:user_id)
expect(user_ids_of_muted_comments.uniq).to eq [original_commenter.id]
end
it "does not mute someone else's comment of a different thread" do
expect(parent_comment_by_other.receive_notifications).to be true
end
it "does not mute the other commenter's comments in the same thread" do
results = parent_comment_by_og.subtree.where(user: other_commenter).pluck(:receive_notifications)
expect(results.uniq).to eq [true]
end
end
end
end

View file

@ -1,7 +1,7 @@
# http://localhost:3000/api/comments?a_id=23
require "rails_helper"
RSpec.describe "ArticlesApi", type: :request do
RSpec.describe "CommentsApi", type: :request do
let(:article) { create(:article) }
before do