diff --git a/app/assets/images/book.svg b/app/assets/images/book.svg
new file mode 100644
index 000000000..2506a4445
--- /dev/null
+++ b/app/assets/images/book.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/app/assets/images/chevron-right.svg b/app/assets/images/chevron-right.svg
new file mode 100644
index 000000000..08ba384a6
--- /dev/null
+++ b/app/assets/images/chevron-right.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/app/assets/images/chevron-toggle.svg b/app/assets/images/chevron-toggle.svg
new file mode 100755
index 000000000..302024376
--- /dev/null
+++ b/app/assets/images/chevron-toggle.svg
@@ -0,0 +1 @@
+
diff --git a/app/assets/images/emoji/emoji-one-thumbs-up-gray.png b/app/assets/images/emoji/emoji-one-thumbs-up-gray.png
new file mode 100644
index 000000000..9efcb22eb
Binary files /dev/null and b/app/assets/images/emoji/emoji-one-thumbs-up-gray.png differ
diff --git a/app/assets/images/emoji/emoji-one-thumbs-up.png b/app/assets/images/emoji/emoji-one-thumbs-up.png
new file mode 100644
index 000000000..4e7929a76
Binary files /dev/null and b/app/assets/images/emoji/emoji-one-thumbs-up.png differ
diff --git a/app/assets/images/mod-filled.svg b/app/assets/images/mod-filled.svg
new file mode 100644
index 000000000..8dacf8301
--- /dev/null
+++ b/app/assets/images/mod-filled.svg
@@ -0,0 +1 @@
+
diff --git a/app/assets/images/plus.svg b/app/assets/images/plus.svg
new file mode 100644
index 000000000..3674df13e
--- /dev/null
+++ b/app/assets/images/plus.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/app/assets/images/subtract.svg b/app/assets/images/subtract.svg
new file mode 100644
index 000000000..4e802d543
--- /dev/null
+++ b/app/assets/images/subtract.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/app/assets/javascripts/initializers/initializeBaseUserData.js b/app/assets/javascripts/initializers/initializeBaseUserData.js
index 3c8118628..801fbf257 100644
--- a/app/assets/javascripts/initializers/initializeBaseUserData.js
+++ b/app/assets/javascripts/initializers/initializeBaseUserData.js
@@ -55,11 +55,6 @@ function addRelevantButtonsToArticle(user) {
);
}
document.getElementById('action-space').innerHTML = actions.join('');
- } else if (user.trusted) {
- document.getElementById('action-space').innerHTML =
- 'MODERATE POST ';
}
}
}
@@ -99,6 +94,20 @@ function addRelevantButtonsToComments(user) {
function initializeBaseUserData() {
const user = userData();
+ const userProfileLinkHTML =
+ '' +
+ '' +
+ user.name +
+ ' ' +
+ '@' +
+ user.username +
+ ' ' +
+ '
';
+ document.getElementById(
+ 'user-profile-link-placeholder',
+ ).innerHTML = userProfileLinkHTML;
const userNavLink = document.getElementById('first-nav-link');
userNavLink.href = `/${user.username}`;
userNavLink.querySelector('span').textContent = user.name;
diff --git a/app/assets/stylesheets/article-show.scss b/app/assets/stylesheets/article-show.scss
index 652385c4d..64ece45b6 100644
--- a/app/assets/stylesheets/article-show.scss
+++ b/app/assets/stylesheets/article-show.scss
@@ -31,6 +31,92 @@
height: 10px;
}
+.mod-actions-menu {
+ position: fixed;
+ top: var(--header-height);
+ right: 0;
+ height: calc(100vh - var(--header-height));
+ z-index: 200;
+ transform: translateX(360px);
+ transition: var(--transition-props);
+ &.showing {
+ transform: initial;
+ }
+
+ iframe {
+ height: 100%;
+ border: none;
+ box-shadow: -4px 0px 15px rgba(0, 0, 0, 0.1);
+ width: 360px;
+ }
+}
+
+.mod-actions-menu-btn {
+ position: fixed;
+ bottom: var(--su-7);
+ right: var(--su-7);
+ z-index: 201;
+ cursor: pointer;
+ transition: 0.3s all ease;
+ border-radius: 50px;
+
+ @media (max-width: $breakpoint-m) {
+ bottom: 90px;
+ }
+}
+
+.actions-menu-svg {
+ color: white;
+}
+
+.mod-actions-menu-btn,
+.actions-menu-svg {
+ transition: 0.3s all ease;
+}
+
+.actions-menu-svg:hover {
+ color: white;
+}
+
+.flag-user-modal {
+ .flag-user-modal-header {
+ padding-left: var(--su-4);
+
+ h2 {
+ font-weight: var(--fw-bold);
+ font-size: var(--fs-l);
+ line-height: var(--lh-tight);
+ }
+
+ .modal-header-close-icon {
+ border-radius: 100%;
+ background: var(--base-10);
+ }
+ }
+
+ .flag-user-modal-body {
+ padding: var(--su-6);
+
+ span {
+ font-size: var(--fs-base);
+ line-height: var(--lh-base);
+ color: var(--base-90);
+ }
+
+ .crayons-fields {
+ margin-top: var(--su-4);
+
+ .abuse-report-link {
+ margin: var(--su-4) 0 0 var(--su-7);
+ }
+ }
+
+ .buttons-container {
+ margin-top: var(--su-8);
+ }
+ }
+}
+
.video-player-header {
background: $black;
margin-top: 0;
diff --git a/app/assets/stylesheets/moderators.scss b/app/assets/stylesheets/moderators.scss
index bfceaa968..38844dacb 100644
--- a/app/assets/stylesheets/moderators.scss
+++ b/app/assets/stylesheets/moderators.scss
@@ -139,13 +139,17 @@
}
.mod-index-row-iframes {
padding: 5px 0px;
+ display: flex;
iframe {
height: calc(100vh - 100px);
max-height: 800px;
- width: 49%;
+ width: 100%;
border: 0px;
display: inline-block;
}
+ .actions-panel {
+ width: 36%;
+ }
}
}
}
@@ -155,8 +159,452 @@
padding: 30px 10px;
}
+// these styles are to correct height and margin issues in the iframe
+.moderations-actions_panel {
+ min-height: initial;
+ height: 100%;
+ #page-content-inner {
+ margin-bottom: 0;
+ height: 100%;
+ }
+}
+
.mod-container {
- padding: 30px 0px;
+ display: flex;
+ flex-direction: column;
+ background-color: var(--base-0);
+ border: none;
+ box-shadow: none;
+ margin-bottom: 0;
+ height: 100%;
+ .circle {
+ border-radius: 100%;
+ &.centered-icon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ }
+ }
+
+ header {
+ &.top-header {
+ background-color: var(--base-inverted);
+ padding: var(--su-4) var(--su-4) 0;
+ display: flex;
+ flex-direction: column;
+ }
+ &.other-things {
+ padding: var(--su-4);
+ h1 {
+ text-transform: uppercase;
+ font-size: var(--fs-s);
+ }
+ }
+ h1 {
+ font-size: var(--fs-l);
+ font-weight: var(--fw-bold);
+ }
+ h2 {
+ font-size: var(--fs-base);
+ font-weight: var(--fw-normal);
+ color: var(--base-70);
+ }
+ button.close-actions-panel {
+ border: none;
+ width: 40px;
+ height: 40px;
+ position: absolute;
+ right: var(--su-4);
+ top: var(--su-4);
+ background-color: var(--base-10);
+ }
+ }
+ .reactions-container {
+ padding: var(--su-4);
+ background-color: var(--base-inverted);
+ }
+
+ .thumb-reactions-container {
+ display: grid;
+ grid-gap: var(--su-4);
+ grid-template-columns: 1fr 1fr;
+ }
+
+ .reaction-button {
+ position: relative;
+ background: transparent;
+ border-color: transparent;
+ border: 0;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ height: var(--su-10);
+ padding: var(--su-4) var(--su-6);
+ border-radius: var(--radius);
+
+ &:hover {
+ background: rgba(59, 73, 223, 0.05);
+ }
+
+ &:hover .reaction-button-circle {
+ border-color: var(--accent-brand);
+ }
+ }
+
+ .reaction-button-circle {
+ position: relative;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ margin: 0 auto;
+ width: var(--su-9);
+ height: var(--su-9);
+ border: solid 2px var(--base-20);
+ border-radius: 100%;
+ background: var(--base-inverted);
+
+ img {
+ width: var(--su-7);
+ }
+
+ .reaction-checkmark {
+ opacity: 0;
+ position: absolute;
+ bottom: 0;
+ right: 0;
+ width: var(--su-5);
+ height: var(--su-5);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background-color: var(--accent-success);
+ border-radius: 100%;
+ border: 2px solid var(--base-inverted);
+ padding: 2px;
+ color: var(--base-inverted);
+ }
+ }
+
+ .vote-text {
+ font-weight: var(--fw-bold);
+ font-size: var(--fs-base);
+ line-height: var(--lh-base);
+ margin-top: var(--su-2);
+ color: var(--base-90);
+ }
+
+ .reaction-vomit-button {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border: none;
+ background: var(--base-inverted);
+ height: var(--su-7);
+ padding: var(--su-1) var(--su-2);
+ margin: var(--su-4) auto;
+ border-radius: var(--radius);
+
+ img {
+ margin-right: var(--su-2);
+ width: var(--su-6);
+ height: var(--su-6);
+ }
+
+ span {
+ font-weight: var(--fw-medium);
+ font-size: var(--fs-s);
+ line-height: var(--lh-tight);
+ color: var(--base-90);
+ }
+
+ .vomit-checkmark {
+ width: var(--su-5);
+ height: auto;
+ font-weight: var(--fw-heavy);
+ margin-left: var(--su-2);
+ color: var(--accent-brand);
+ display: none;
+ }
+
+ &:hover {
+ background: rgba(59, 73, 223, 0.05);
+ }
+ }
+
+ .how-this-works-section {
+ font-weight: var(--fw-medium);
+ font-size: var(--fs-xs);
+ line-height: var(--lh-base);
+ letter-spacing: -0.02em;
+ color: var(--base-70);
+ cursor: pointer;
+ padding: var(--su-4) var(--su-3);
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ svg {
+ width: var(--su-4);
+ height: var(--su-4);
+ padding: var(--su-1);
+ path {
+ fill: var(--base-70);
+ }
+ }
+ }
+
+ .reaction-button.reacted {
+ background: var(--accent-brand);
+
+ .reaction-button-circle {
+ border-color: var(--accent-brand-lighter);
+ }
+
+ .vote-text {
+ color: var(--base-inverted);
+ }
+
+ .reaction-checkmark {
+ opacity: 100;
+ }
+ }
+
+ .reaction-vomit-button.reacted {
+ background: rgba(59, 73, 223, 0.1);
+
+ .vomit-checkmark {
+ display: inline;
+ }
+
+ span {
+ color: var(--accent-brand);
+
+ &:after {
+ content: 'Marked as abuse/spam';
+ }
+ }
+ }
+
+ .reaction-button .reacted-emoji,
+ .reaction-vomit-button .reacted-emoji {
+ display: none;
+ }
+
+ .reaction-button.reacted .emoji-grey,
+ .reaction-vomit-button.reacted .emoji-grey {
+ display: none;
+ }
+
+ .reaction-button.reacted .reacted-emoji,
+ .reaction-vomit-button.reacted .reacted-emoji {
+ display: block;
+ }
+ .other-things-container {
+ border-top: 1px solid var(--base-20);
+ overflow-y: auto;
+ button.other-things-btn {
+ background: inherit;
+ border: none;
+ padding: var(--su-3) var(--su-4);
+ width: 100%;
+ display: flex;
+ justify-content: space-between;
+ .label-wrapper {
+ display: flex;
+ align-items: center;
+ .icon {
+ background-color: var(--base-20);
+ width: 40px;
+ height: 40px;
+ }
+ header {
+ text-align: left;
+ margin-left: var(--su-3);
+ display: flex;
+ flex-direction: column;
+ width: 240px;
+ h2 {
+ color: var(--base-100);
+ font-size: var(--fs-base);
+ font-weight: var(--fw-medium);
+ }
+ h3 {
+ color: var(--base-70);
+ font-size: var(--fs-s);
+ font-weight: var(--fw-normal);
+ }
+ }
+ }
+ .toggle-chevron-container {
+ width: 16px;
+ height: 16px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ align-self: center;
+ transition-duration: 0.4s;
+ svg {
+ fill: var(--base-80);
+ }
+ &.rotated {
+ transform: rotateZ(-180deg);
+ }
+ }
+ &:hover {
+ background-color: var(--base-inverted);
+ .label-wrapper > .icon {
+ background-color: var(--accent-brand);
+ svg {
+ fill: var(--base-inverted);
+ }
+ }
+ header h2 {
+ color: var(--accent-brand);
+ }
+ }
+ &.active {
+ header h2 {
+ font-weight: var(--fw-bold);
+ }
+ }
+ &.inactive:not(.active) {
+ header h2 {
+ color: var(--base-50);
+ }
+ header h3 {
+ color: var(--base-40);
+ }
+ .icon {
+ background-color: var(--base-20);
+ svg {
+ fill: var(--base-50);
+ }
+ }
+ .toggle-chevron-container > svg {
+ fill: var(--base-60);
+ }
+ }
+ }
+ .dropdown-options {
+ display: flex;
+ flex-direction: column;
+ padding: 0 var(--su-4);
+ transition-duration: 1s;
+ }
+ .adjust-tags-options {
+ .add-tag-container {
+ padding: var(--su-2) var(--su-1);
+ }
+ button.adjustable-tag {
+ background: inherit;
+ border: none;
+ padding: var(--su-2) 2px;
+ font-size: var(--fs-base);
+ text-align: left;
+ color: var(--base-80);
+ font-family: var(--ff-accent);
+ .num-sign {
+ color: var(--base-40);
+ padding-left: 2px;
+ }
+ .adjustment-icon {
+ width: 24px;
+ height: 24px;
+ float: right;
+ margin-right: 2px;
+ &.subtract {
+ background: var(--accent-danger);
+ }
+ &.plus {
+ background: var(--accent-success-darker);
+ }
+ svg > path {
+ fill: var(--base-inverted);
+ }
+ }
+ &:hover {
+ background: rgba(59, 73, 223, 0.05);
+ }
+ &.active {
+ background: rgba(59, 73, 223, 0.05);
+ }
+ // &.selected {}
+ }
+ .adjustment-reason-container {
+ display: flex;
+ flex-direction: column;
+ padding: var(--su-1);
+ textarea {
+ margin-bottom: var(--su-2);
+ }
+ }
+ }
+ .set-experience-options {
+ button.level-rating-button {
+ background: inherit;
+ border: none;
+ padding: var(--su-2) 0.125rem;
+ width: 100%;
+ display: flex;
+ justify-content: space-between;
+
+ &:hover {
+ background: rgba(59, 73, 223, 0.05);
+ }
+
+ .level-rating-content {
+ display: flex;
+
+ .level-rating-number {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-weight: var(--fw-heavy);
+ font-size: var(--fs-base);
+ line-height: var(--lh-base);
+ color: var(--base-100);
+ width: var(--su-6);
+ }
+
+ .level-rating-text {
+ font-weight: var(--fw-normal);
+ font-size: var(--fs-base);
+ line-height: var(--lh-base);
+ color: var(--base-90);
+ }
+ }
+
+ svg {
+ color: var(--accent-brand);
+ width: var(--su-5);
+ height: auto;
+ margin-right: var(--su-2);
+ align-self: center;
+ display: none;
+ }
+ }
+
+ .level-rating-button.selected {
+ background: rgba(59, 73, 223, 0.05);
+ svg {
+ display: inline;
+ }
+ }
+ }
+ }
+ .bottom-actions {
+ display: flex;
+ justify-content: center;
+ padding-bottom: var(--su-4);
+ margin-top: auto;
+ button {
+ display: initial;
+ font-size: var(--fs-base);
+ color: var(--accent-brand);
+ border: none;
+ background: none;
+ }
+ }
+
h2 {
a {
color: var(--body-color);
@@ -184,39 +632,14 @@
margin: 90px auto;
border-radius: 12px;
}
- .reaction-button {
- position: relative;
- width: 80px;
- height: 80px;
- background: white;
- border-color: white;
- margin: 30px 30px;
- border-radius: 200px;
- border: 0px;
- padding: 12px 12px;
- &:active {
- border: 0px;
- }
- }
-
- .reaction-button img {
- position: absolute;
- top: 5px;
- width: 70px;
- left: 5px;
- }
-
- .reaction-button .reacted-emoji {
- display: none;
- }
-
- .reaction-button.reacted .reacted-emoji {
- display: block;
- }
.tag-mod-form {
border-top: 1px solid #dddddd;
margin: 42px auto;
+
+ .level-rating-button.article-mod-page.selected {
+ background: #9bebff;
+ }
}
.tag-mod-form ul {
@@ -272,19 +695,6 @@
border: 1px solid var(--card-color-tertiary);
}
- .level-rating-button {
- border-radius: 8px;
- font-size: 1.1em;
- padding: 3px;
- width: 42px;
- border: 2px solid black;
- font-weight: bold;
- }
-
- .level-rating-button.selected {
- background: #9bebff;
- }
-
input.undo {
background: $red;
}
diff --git a/app/controllers/internal/application_controller.rb b/app/controllers/internal/application_controller.rb
index 6cde44f74..f03ec7a29 100644
--- a/app/controllers/internal/application_controller.rb
+++ b/app/controllers/internal/application_controller.rb
@@ -16,7 +16,7 @@ class Internal::ApplicationController < ApplicationController
{ name: "listings", controller: "listings" },
{ name: "moderator_actions", controller: "moderator_actions" },
{ name: "mods", controller: "mods" },
- { name: "negative_reactions", controller: "negative_reactions" },
+ { name: "privileged_reactions", controller: "privileged_reactions" },
{ name: "organizations", controller: "organizations" },
{ name: "path_redirects", controller: "path_redirects" },
{ name: "pages", controller: "pages" },
diff --git a/app/controllers/internal/negative_reactions_controller.rb b/app/controllers/internal/negative_reactions_controller.rb
deleted file mode 100644
index 97bab9747..000000000
--- a/app/controllers/internal/negative_reactions_controller.rb
+++ /dev/null
@@ -1,15 +0,0 @@
-class Internal::NegativeReactionsController < Internal::ApplicationController
- layout "internal"
-
- NEGATIVE_REACTION_CATEGORIES = %i[vomit thumbsdown].freeze
-
- def index
- @q = Reaction.
- includes(:user,
- :reactable).
- where("category IN (?)", NEGATIVE_REACTION_CATEGORIES).
- order("reactions.created_at DESC").
- ransack(params[:q])
- @negative_reactions = @q.result.page(params[:page] || 1).per(25)
- end
-end
diff --git a/app/controllers/internal/privileged_reactions_controller.rb b/app/controllers/internal/privileged_reactions_controller.rb
new file mode 100644
index 000000000..f3b10422e
--- /dev/null
+++ b/app/controllers/internal/privileged_reactions_controller.rb
@@ -0,0 +1,15 @@
+class Internal::PrivilegedReactionsController < Internal::ApplicationController
+ layout "internal"
+
+ PRIVILEGED_REACTION_CATEGORIES = %i[thumbsup thumbsdown vomit].freeze
+
+ def index
+ @q = Reaction.
+ includes(:user,
+ :reactable).
+ where("category IN (?)", PRIVILEGED_REACTION_CATEGORIES).
+ order("reactions.created_at DESC").
+ ransack(params[:q])
+ @privileged_reactions = @q.result.page(params[:page] || 1).per(25)
+ end
+end
diff --git a/app/controllers/moderations_controller.rb b/app/controllers/moderations_controller.rb
index 9ca5f1d75..462577f1c 100644
--- a/app/controllers/moderations_controller.rb
+++ b/app/controllers/moderations_controller.rb
@@ -15,6 +15,31 @@ class ModerationsController < ApplicationController
end
def article
+ load_article
+ render template: "moderations/mod"
+ end
+
+ def comment
+ authorize(User, :moderation_routes?)
+ @moderatable = Comment.find(params[:id_code].to_i(26))
+ render template: "moderations/mod"
+ end
+
+ def actions_panel
+ load_article
+ tag_mod_tag_ids = @tag_moderator_tags.pluck(:id)
+ has_room_for_tags = @moderatable.tag_list.size < 4
+ has_no_relevant_adjustments = @adjustments.pluck(:tag_id).intersection(tag_mod_tag_ids).size.zero?
+ can_be_adjusted = @moderatable.tags.pluck(:id).intersection(tag_mod_tag_ids).size.positive?
+
+ @should_show_adjust_tags = tag_mod_tag_ids.size.positive? && ((has_room_for_tags && has_no_relevant_adjustments) || (!has_room_for_tags && has_no_relevant_adjustments && can_be_adjusted))
+
+ render template: "moderations/actions_panel"
+ end
+
+ private
+
+ def load_article
authorize(User, :moderation_routes?)
@tag_adjustment = TagAdjustment.new
@moderatable = Article.find_by(slug: params[:slug])
@@ -24,12 +49,5 @@ class ModerationsController < ApplicationController
@already_adjusted_tags = @adjustments.map(&:tag_name).join(", ")
@allowed_to_adjust = @moderatable.class.name == "Article" && (current_user.has_role?(:super_admin) || @tag_moderator_tags.any?)
@hidden_comments = @moderatable.comments.where(hidden_by_commentable_user: true)
- render template: "moderations/mod"
- end
-
- def comment
- authorize(User, :moderation_routes?)
- @moderatable = Comment.find(params[:id_code].to_i(26))
- render template: "moderations/mod"
end
end
diff --git a/app/controllers/rating_votes_controller.rb b/app/controllers/rating_votes_controller.rb
index b90482de2..ba6edd4e2 100644
--- a/app/controllers/rating_votes_controller.rb
+++ b/app/controllers/rating_votes_controller.rb
@@ -12,9 +12,15 @@ class RatingVotesController < ApplicationController
rating_vote.rating = rating_vote_params[:rating].to_f
rating_vote.group = rating_vote_params[:group]
if rating_vote.save
- redirect_back(fallback_location: "/mod")
+ respond_to do |format|
+ format.json { render json: { result: "Success" } }
+ format.html { redirect_back(fallback_location: "/mod") }
+ end
else
- render json: { result: "Not Upserted Successfully" }
+ respond_to do |format|
+ format.json { render json: { error: rating_vote.errors.full_messages.to_sentence }, status: :unprocessable_entity }
+ format.html { render json: { result: "Not Upserted Successfully" } }
+ end
end
end
diff --git a/app/controllers/reactions_controller.rb b/app/controllers/reactions_controller.rb
index 19e6ed94a..e78ba3b60 100644
--- a/app/controllers/reactions_controller.rb
+++ b/app/controllers/reactions_controller.rb
@@ -3,6 +3,9 @@ class ReactionsController < ApplicationController
before_action :authorize_for_reaction, :check_limit, only: [:create]
after_action :verify_authorized
+ NEGATIVE_CATEGORIES = %w[thumbsdown vomit].freeze
+ MODERATION_CATEGORIES = %w[thumbsup thumbsdown vomit].freeze
+
def index
skip_authorization
@@ -51,7 +54,17 @@ class ReactionsController < ApplicationController
def create
remove_count_cache_key
+ if params[:reactable_type] == "Article" && params[:category].in?(MODERATION_CATEGORIES)
+ clear_moderator_reactions(
+ params[:reactable_id],
+ params[:reactable_type],
+ current_user,
+ params[:category],
+ )
+ end
+
category = params[:category] || "like"
+
reaction = Reaction.where(
user_id: current_user.id,
reactable_id: params[:reactable_id],
@@ -61,13 +74,7 @@ class ReactionsController < ApplicationController
# if the reaction already exists, destroy it
if reaction
- result = destroy_reaction(reaction)
-
- if reaction.negative? && current_user.auditable?
- updated_params = params.dup
- updated_params[:action] = "destroy"
- Audit::Logger.log(:moderator, current_user, updated_params)
- end
+ result = handle_existing_reaction(reaction)
else
reaction = build_reaction(category)
@@ -84,7 +91,7 @@ class ReactionsController < ApplicationController
rate_article(reaction)
end
- if reaction.negative? && current_user.auditable?
+ if current_user.auditable?
Audit::Logger.log(:moderator, current_user, params.dup)
end
else
@@ -130,6 +137,30 @@ class ReactionsController < ApplicationController
rating: current_user.experience_level)
end
+ def clear_moderator_reactions(id, type, mod, category)
+ reactions = if category == "thumbsup"
+ Reaction.where(reactable_id: id, reactable_type: type, user: mod).where.not(category: category)
+ elsif category.in?(NEGATIVE_CATEGORIES)
+ Reaction.where(reactable_id: id, reactable_type: type, user: mod, category: "thumbsup")
+ end
+
+ return if reactions.blank?
+
+ reactions.find_each { |reaction| destroy_reaction(reaction) }
+ end
+
+ def handle_existing_reaction(reaction)
+ result = destroy_reaction(reaction)
+
+ if reaction.negative? && current_user.auditable?
+ updated_params = params.dup
+ updated_params[:action] = "destroy"
+ Audit::Logger.log(:moderator, current_user, updated_params)
+ end
+
+ result
+ end
+
def check_limit
rate_limit!(:reaction_creation)
end
diff --git a/app/controllers/tag_adjustments_controller.rb b/app/controllers/tag_adjustments_controller.rb
index 436a084b6..ff6d59b0c 100644
--- a/app/controllers/tag_adjustments_controller.rb
+++ b/app/controllers/tag_adjustments_controller.rb
@@ -17,8 +17,13 @@ class TagAdjustmentsController < ApplicationController
article = service.article
if tag_adjustment.save
service.update_tags_and_notify
- redirect_to "#{URI.parse(article.path).path}/mod"
+ tag = tag_adjustment.tag
+ respond_to do |format|
+ format.json { render json: { status: "Success", result: tag_adjustment.adjustment_type, colors: { bg: tag.bg_color_hex, text: tag.text_color_hex } } }
+ format.html { redirect_to "#{URI.parse(article.path).path}/mod" }
+ end
else
+ # TODO: remove this when we move over to full JSON endpoint
authorize(User, :moderation_routes?)
@tag_adjustment = tag_adjustment
@moderatable = article
@@ -26,7 +31,10 @@ class TagAdjustmentsController < ApplicationController
@adjustments = TagAdjustment.where(article_id: article.id)
@already_adjusted_tags = @adjustments.map(&:tag_name).join(", ")
@allowed_to_adjust = @moderatable.class.name == "Article" && (current_user.any_admin? || @tag_moderator_tags.any?)
- render template: "moderations/mod"
+ respond_to do |format|
+ format.json { render json: { error: "Failure: #{tag_adjustment.errors.full_messages.to_sentence}" } }
+ format.html { render template: "moderations/mod" }
+ end
end
end
@@ -37,6 +45,10 @@ class TagAdjustmentsController < ApplicationController
@article = Article.find(tag_adjustment.article_id)
@article.update!(tag_list: @article.tag_list.add(tag_adjustment.tag_name)) if tag_adjustment.adjustment_type == "removal"
@article.update!(tag_list: @article.tag_list.remove(tag_adjustment.tag_name)) if tag_adjustment.adjustment_type == "addition"
- redirect_to "#{URI.parse(@article.path).path}/mod"
+ respond_to do |format|
+ # TODO: add tag adjustment removal async route in actions panel
+ format.json { render json: { result: "Tag adjustment destroyed" } }
+ format.html { redirect_to "#{URI.parse(@article.path).path}/mod" }
+ end
end
end
diff --git a/app/javascript/actionsPanel/__tests__/actionsPanel.test.js b/app/javascript/actionsPanel/__tests__/actionsPanel.test.js
new file mode 100644
index 000000000..c00e70633
--- /dev/null
+++ b/app/javascript/actionsPanel/__tests__/actionsPanel.test.js
@@ -0,0 +1,236 @@
+/* eslint-disable no-restricted-globals */
+import fetch from 'jest-fetch-mock';
+import {
+ addCloseListener,
+ initializeHeight,
+ addReactionButtonListeners,
+ addAdjustTagListeners,
+} from '../actionsPanel';
+
+describe('addCloseListener()', () => {
+ test('toggles the mod actions panel and its button on click', () => {
+ document.body.innerHTML = `
+
+
+
+
+ `;
+ addCloseListener();
+
+ const closeButton = document.querySelector('.close-actions-panel');
+ closeButton.click();
+ // eslint-disable-next-line no-restricted-globals
+ const modPanel = top.document.querySelector('.mod-actions-menu');
+ const modPanelBtn = top.document.querySelector('.mod-actions-menu-btn');
+ expect(modPanel.classList).not.toContain('showing');
+ expect(modPanelBtn.classList).not.toContain('hidden');
+ });
+});
+
+describe('initializeHeight()', () => {
+ test('it sets the height of the proper elements', () => {
+ document.body.innerHTML = `
+
+
+
+ `;
+ initializeHeight();
+
+ const { body } = document;
+ expect(document.documentElement.style.height).toEqual('100%');
+ expect(body.style.height).toEqual('100%');
+ expect(body.style.margin).toEqual('0px');
+ expect(body.style.marginTop).toEqual('0px');
+ expect(body.style.marginBottom).toEqual('0px');
+ expect(body.style.paddingTop).toEqual('0px');
+ expect(body.style.paddingTop).toEqual('0px');
+ });
+});
+
+describe('addReactionButtonListeners()', () => {
+ beforeEach(() => {
+ fetch.resetMocks();
+
+ document.body.innerHTML = `
+
+
+
+
+
+
+ `;
+
+ const csrfToken = 'this-is-a-csrf-token';
+
+ window.fetch = fetch;
+ window.getCsrfToken = async () => csrfToken;
+ top.addSnackbarItem = jest.fn();
+ });
+
+ function sampleResponse(category, create = true) {
+ return JSON.stringify({
+ outcome: {
+ result: create ? 'create' : 'destroy',
+ category,
+ },
+ });
+ }
+
+ describe('when no reactions are already reacted on', () => {
+ test('it marks thumbs up reaction as reacted', async () => {
+ let category = 'thumbsup';
+ fetch.mockResponse(sampleResponse(category));
+ addReactionButtonListeners();
+
+ const thumbsupButton = document.querySelector(
+ `.reaction-button[data-category="${category}"]`,
+ );
+ thumbsupButton.click();
+ expect(thumbsupButton.classList).toContain('reacted');
+
+ category = 'thumbsdown';
+ const thumbsdownButton = document.querySelector(
+ `.reaction-button[data-category="${category}"]`,
+ );
+ thumbsdownButton.click();
+ expect(thumbsdownButton.classList).toContain('reacted');
+
+ category = 'vomit';
+ fetch.resetMocks();
+ fetch.mockResponse(sampleResponse(category));
+ const vomitButton = document.querySelector(
+ `.reaction-vomit-button[data-category="${category}"]`,
+ );
+ vomitButton.click();
+ expect(vomitButton.classList).toContain('reacted');
+ });
+ test('it unmarks the proper reaction(s) when positive/negative reactions are clicked', async () => {
+ let category = 'thumbsup';
+ fetch.mockResponse(sampleResponse(category));
+ addReactionButtonListeners();
+ const thumbsupButton = document.querySelector(
+ `.reaction-button[data-category="${category}"]`,
+ );
+ thumbsupButton.click();
+
+ category = 'thumbsdown';
+ fetch.resetMocks();
+ fetch.mockResponse(sampleResponse(category));
+ const thumbsdownButton = document.querySelector(
+ `.reaction-button[data-category="${category}"]`,
+ );
+ thumbsdownButton.click();
+ expect(thumbsupButton.classList).not.toContain('reacted');
+
+ fetch.resetMocks();
+ category = 'thumbsup';
+ fetch.mockResponse(sampleResponse(category, false));
+ thumbsupButton.click();
+ expect(thumbsdownButton.classList).not.toContain('reacted');
+ expect(thumbsupButton.classList).toContain('reacted');
+
+ category = 'vomit';
+ fetch.resetMocks();
+ fetch.mockResponse(sampleResponse(category));
+ const vomitButton = document.querySelector(
+ `.reaction-vomit-button[data-category="${category}"]`,
+ );
+ vomitButton.click();
+ expect(vomitButton.classList).toContain('reacted');
+ expect(thumbsupButton.classList).not.toContain('reacted');
+ });
+ });
+});
+
+describe('addAdjustTagListeners()', () => {
+ describe('when the user is tag moderator of #discuss', () => {
+ beforeEach(() => {
+ const tagName = 'discuss';
+ document.body.innerHTML = `
+ ${tagName}
+
+ #${tagName}
+
+
+ `;
+
+ addAdjustTagListeners();
+ });
+ describe('when an article is tagged with #discuss', () => {
+ it('toggles the tag button and the form', () => {
+ const tagBtn = document.querySelector('.adjustable-tag');
+ tagBtn.click();
+ expect(tagBtn.classList).toContain('active');
+ expect(
+ document.getElementById('adjustment-reason-container').classList,
+ ).not.toContain('hidden');
+ });
+ it('hides the form if the button is clicked again', () => {
+ const tagBtn = document.querySelector('.adjustable-tag');
+ tagBtn.click();
+ tagBtn.click();
+ expect(
+ document.getElementById('adjustment-reason-container').classList,
+ ).toContain('hidden');
+ });
+ });
+ });
+
+ describe('when the user is an admin and the article has room for tags', () => {
+ describe('tag adjustment interactions', () => {
+ beforeEach(() => {
+ const tagName = 'discuss';
+ document.body.innerHTML = `
+
+
+
+ ${tagName}
+
+ #${tagName}
+
+
+ #ruby
+
+
+
+ Submit
+
+ `;
+ addAdjustTagListeners();
+ });
+ it('shows the adjustment container when admin input is focused', () => {
+ document.getElementById('admin-add-tag').focus();
+ expect(
+ document.getElementById('adjustment-reason-container').classList,
+ ).not.toContain('hidden');
+ });
+ it('triggers a confirmation if the admin add tag input was filled in', () => {
+ window.confirm = jest.fn();
+ document.getElementById('admin-add-tag').value = 'pizza';
+ document.querySelector('.adjustable-tag[data-tag-name="ruby"]').click();
+ expect(window.confirm).toHaveBeenCalled();
+ });
+ it('does not the hide reason container when going from one tag to another tag', () => {
+ document.querySelector('.adjustable-tag').click();
+ document.querySelector('.adjustable-tag[data-tag-name="ruby"]').click();
+ expect(
+ document.getElementById('adjustment-reason-container').classList,
+ ).not.toContain('hidden');
+ });
+ });
+ });
+});
+
+/* eslint-enable no-restricted-globals */
diff --git a/app/javascript/actionsPanel/__tests__/initializeActionsPanelToggle.test.js b/app/javascript/actionsPanel/__tests__/initializeActionsPanelToggle.test.js
new file mode 100644
index 000000000..b056f1f3b
--- /dev/null
+++ b/app/javascript/actionsPanel/__tests__/initializeActionsPanelToggle.test.js
@@ -0,0 +1,33 @@
+import initializeActionsPanel from '../initializeActionsPanelToggle';
+
+describe('toggling the actions panel', () => {
+ describe('when the page is the article show page', () => {
+ document.body.innerHTML = `
+
+
+ `;
+ const path = '/fakeuser/fake-article-slug-1d3a';
+
+ test('it should render the mod actions menu button', () => {
+ initializeActionsPanel(path);
+ expect(
+ document.querySelector(
+ `iframe#mod-container[src="${path}/actions_panel"]`,
+ ),
+ ).toBeDefined();
+ expect(document.querySelector(`.mod-actions-menu-btn`)).not.toBeNull();
+ expect(document.querySelector(`.actions-menu-svg`)).not.toBeNull();
+ });
+
+ test('it should have a click listener that toggles the appropriate classes', () => {
+ initializeActionsPanel(path);
+ const modActionsMenu = document.querySelector('.mod-actions-menu');
+ const modActionsMenuBtn = document.querySelector('.mod-actions-menu-btn');
+
+ modActionsMenuBtn.click();
+
+ expect(modActionsMenu.classList.contains('showing')).toBeTruthy();
+ expect(modActionsMenuBtn.classList.contains('hidden')).toBeTruthy();
+ });
+ });
+});
diff --git a/app/javascript/actionsPanel/actionsPanel.js b/app/javascript/actionsPanel/actionsPanel.js
new file mode 100644
index 000000000..0ace80cba
--- /dev/null
+++ b/app/javascript/actionsPanel/actionsPanel.js
@@ -0,0 +1,370 @@
+import { request } from '@utilities/http';
+
+export function addCloseListener() {
+ const button = document.querySelector('.close-actions-panel');
+ button.addEventListener('click', () => {
+ // getting the article show page document because this is called within an iframe
+ // eslint-disable-next-line no-restricted-globals
+ const articleDocument = top.document;
+
+ articleDocument
+ .querySelector('.mod-actions-menu')
+ .classList.toggle('showing');
+ articleDocument
+ .querySelector('.mod-actions-menu-btn')
+ .classList.toggle('hidden');
+ });
+}
+
+export function initializeHeight() {
+ document.documentElement.style.height = '100%';
+ document.body.style.cssText = 'height: 100%; margin: 0; padding-top: 0;';
+ document.getElementById('page-content').style.cssText =
+ 'margin-top: 0 !important; margin-bottom: 0;';
+}
+
+function toggleDropdown(type) {
+ if (type === 'set-experience') {
+ document
+ .querySelector('.set-experience-options')
+ .classList.toggle('hidden');
+ } else if (type === 'adjust-tags') {
+ document.querySelector('.adjust-tags-options').classList.toggle('hidden');
+ }
+}
+
+function applyReactedClass(category) {
+ const upVote = document.querySelector("[data-category='thumbsup']");
+ const downVote = document.querySelector("[data-category='thumbsdown']");
+ const vomitVote = document.querySelector("[data-category='vomit']");
+
+ if (category === 'thumbsup') {
+ downVote.classList.remove('reacted');
+ vomitVote.classList.remove('reacted');
+ } else {
+ upVote.classList.remove('reacted');
+ }
+}
+
+export function addReactionButtonListeners() {
+ const butts = Array.from(
+ document.querySelectorAll('.reaction-button, .reaction-vomit-button'),
+ );
+ /* eslint-disable camelcase */
+ butts.forEach((butt) => {
+ butt.addEventListener('click', async (event) => {
+ event.preventDefault();
+ const {
+ reactableType: reactable_type,
+ category,
+ reactableId: reactable_id,
+ } = butt.dataset;
+
+ applyReactedClass(category);
+ butt.classList.toggle('reacted');
+
+ try {
+ const response = await request('/reactions', {
+ method: 'POST',
+ body: { reactable_type, category, reactable_id },
+ });
+
+ const outcome = await response.json();
+
+ let message;
+ /* eslint-disable no-restricted-globals */
+ if (outcome.result === 'create' && outcome.category === 'thumbsup') {
+ message = 'This post will be more visible.';
+ } else if (
+ outcome.result === 'create' &&
+ outcome.category === 'thumbsdown'
+ ) {
+ message = 'This post will be less visible.';
+ } else if (
+ outcome.result === 'create' &&
+ outcome.category === 'vomit'
+ ) {
+ message = "You've flagged this post as abusive or spam.";
+ } else if (outcome.result === 'destroy') {
+ message = 'Your quality rating was removed.';
+ } else if (outcome.error) {
+ message = `Error: ${outcome.error}`;
+ }
+ top.addSnackbarItem({
+ message,
+ addCloseButton: true,
+ });
+ /* eslint-enable no-restricted-globals */
+ } catch (error) {
+ // eslint-disable-next-line no-alert
+ alert(error);
+ }
+ });
+ });
+ /* eslint-enable camelcase */
+}
+
+function clearExpLevels() {
+ Array.from(
+ document.getElementsByClassName('level-rating-button selected'),
+ ).forEach((el) => {
+ el.classList.remove('selected');
+ });
+}
+
+async function updateExperienceLevel(currentUserId, articleId, rating, group) {
+ try {
+ const response = await request('/rating_votes', {
+ method: 'POST',
+ body: JSON.stringify({
+ user_id: currentUserId,
+ article_id: articleId,
+ rating,
+ group,
+ }),
+ });
+
+ const outcome = await response.json();
+
+ if (outcome.result === 'Success') {
+ clearExpLevels();
+ document
+ .getElementById(`js__rating__vote__${rating}`)
+ .classList.add('selected');
+ } else {
+ // eslint-disable-next-line no-alert
+ alert(outcome.error);
+ }
+ } catch (error) {
+ // eslint-disable-next-line no-alert
+ alert(error);
+ }
+}
+
+function toggleSubmitContainer() {
+ document
+ .getElementById('adjustment-reason-container')
+ .classList.toggle('hidden');
+}
+
+function clearAdjustmentReason() {
+ document.getElementById('tag-adjustment-reason').value = '';
+}
+
+function renderTagOnArticle(tagName, colors) {
+ /* eslint-disable no-restricted-globals */
+ let articleTagsContainer;
+ if (top.document.location.pathname.endsWith('/mod')) {
+ [articleTagsContainer] = top.document
+ .getElementById('quick-mod-article')
+ .contentDocument.getElementsByClassName('tags');
+ } else {
+ [articleTagsContainer] = top.document.getElementsByClassName('tags');
+ }
+ /* eslint-enable no-restricted-globals */
+
+ const newTag = document.createElement('a');
+ newTag.innerText = `#${tagName}`;
+ newTag.setAttribute('class', 'tag');
+ newTag.setAttribute('href', `/t/${tagName}`);
+ newTag.style = `background-color: ${colors.bg}; color: ${colors.text};`;
+
+ articleTagsContainer.appendChild(newTag);
+}
+
+async function adjustTag(el) {
+ const reasonForAdjustment = document.getElementById('tag-adjustment-reason')
+ .value;
+ const body = {
+ tag_adjustment: {
+ // TODO: change to tag ID
+ tag_name: el.dataset.tagName || el.value,
+ article_id: el.dataset.articleId,
+ adjustment_type:
+ el.dataset.adjustmentType === 'subtract' ? 'removal' : 'addition',
+ reason_for_adjustment: reasonForAdjustment,
+ },
+ };
+
+ try {
+ const response = await request('/tag_adjustments', {
+ method: 'POST',
+ body: JSON.stringify(body),
+ });
+
+ const outcome = await response.json();
+
+ if (outcome.status === 'Success') {
+ let adjustedTagName;
+ if (el.tagName === 'BUTTON') {
+ adjustedTagName = el.dataset.tagName;
+ el.remove();
+ } else {
+ adjustedTagName = el.value;
+ // eslint-disable-next-line no-param-reassign
+ el.value = '';
+ }
+
+ toggleSubmitContainer();
+ clearAdjustmentReason();
+
+ if (outcome.result === 'addition') {
+ renderTagOnArticle(adjustedTagName, outcome.colors);
+ } else {
+ // eslint-disable-next-line no-restricted-globals
+ const tagOnArticle = top.document.querySelector(
+ `.tag[href="/t/${adjustedTagName}"]`,
+ );
+ tagOnArticle.remove();
+ }
+
+ // eslint-disable-next-line no-restricted-globals
+ top.addSnackbarItem({
+ message: `The #${adjustedTagName} tag was ${
+ outcome.result === 'addition' ? 'added' : 'removed'
+ }.`,
+ addCloseButton: true,
+ });
+ } else {
+ // eslint-disable-next-line no-restricted-globals
+ top.addSnackbarItem({
+ message: `An error occurred: ${outcome.error}`,
+ addCloseButton: true,
+ });
+ }
+ } catch (error) {
+ // eslint-disable-next-line no-alert
+ alert(error);
+ }
+}
+
+export function handleAdjustTagBtn(btn) {
+ const currentActiveTags = document.querySelectorAll(
+ 'button.adjustable-tag.active',
+ );
+ const adminTagInput = document.getElementById('admin-add-tag');
+ /* eslint-disable no-restricted-globals */
+ /* eslint-disable no-alert */
+ if (
+ adminTagInput &&
+ adminTagInput.value !== '' &&
+ confirm(
+ 'This will clear your current "Add a tag" input. Do you want to continue?',
+ )
+ ) {
+ /* eslint-enable no-restricted-globals */
+ /* eslint-enable no-alert */
+ adminTagInput.value = '';
+ } else if (currentActiveTags.length > 0) {
+ currentActiveTags.forEach((tag) => {
+ if (tag !== btn) {
+ tag.classList.remove('active');
+ }
+ btn.classList.toggle('active');
+ });
+ if (btn.classList.contains('active')) {
+ document
+ .getElementById('adjustment-reason-container')
+ .classList.remove('hidden');
+ } else {
+ document
+ .getElementById('adjustment-reason-container')
+ .classList.add('hidden');
+ }
+ } else {
+ btn.classList.toggle('active');
+ toggleSubmitContainer();
+ }
+}
+
+function handleAdminInput() {
+ const addTagInput = document.getElementById('admin-add-tag');
+
+ if (addTagInput) {
+ addTagInput.addEventListener('focus', () => {
+ document
+ .getElementById('adjustment-reason-container')
+ .classList.remove('hidden');
+
+ const activeTagBtns = Array.from(
+ document.querySelectorAll('button.adjustable-tag.active'),
+ );
+ activeTagBtns.forEach((btn) => {
+ btn.classList.remove('active');
+ });
+ });
+ addTagInput.addEventListener('focusout', () => {
+ if (addTagInput.value === '') {
+ toggleSubmitContainer();
+ }
+ });
+ }
+}
+
+export function addAdjustTagListeners() {
+ Array.from(document.getElementsByClassName('adjustable-tag')).forEach(
+ (btn) => {
+ btn.addEventListener('click', () => {
+ handleAdjustTagBtn(btn);
+ });
+ },
+ );
+
+ const adjustTagSubmitBtn = document.getElementById('tag-adjust-submit');
+ if (adjustTagSubmitBtn) {
+ adjustTagSubmitBtn.addEventListener('click', (e) => {
+ e.preventDefault();
+ const textArea = document.getElementById('tag-adjustment-reason');
+ const dataSource =
+ document.querySelector('button.adjustable-tag.active') ||
+ document.getElementById('admin-add-tag');
+
+ if (textArea.checkValidity()) {
+ adjustTag(dataSource);
+ }
+ });
+
+ handleAdminInput();
+ }
+}
+
+export function addBottomActionsListeners() {
+ addAdjustTagListeners();
+ Array.from(document.getElementsByClassName('other-things-btn')).forEach(
+ (btn) => {
+ btn.addEventListener('click', () => {
+ btn.classList.toggle('active');
+ const otherBtns = Array.from(
+ document.getElementsByClassName('other-things-btn'),
+ ).filter((otherBtn) => otherBtn !== btn);
+ otherBtns.forEach((otherBtn) => {
+ otherBtn.classList.toggle('inactive');
+ });
+
+ btn.querySelector('.label-wrapper > .icon').classList.toggle('hidden');
+ btn
+ .querySelector('.toggle-chevron-container')
+ .classList.toggle('rotated');
+ toggleDropdown(btn.dataset.otherThingsType);
+ });
+ },
+ );
+
+ document.querySelectorAll('.level-rating-button').forEach((btn) => {
+ btn.addEventListener('click', () => {
+ updateExperienceLevel(
+ btn.dataset.userId,
+ btn.dataset.articleId,
+ btn.value,
+ btn.dataset.group,
+ );
+ });
+ });
+}
+
+export function initializeActionsPanel() {
+ initializeHeight();
+ addCloseListener();
+ addReactionButtonListeners();
+ addBottomActionsListeners();
+}
diff --git a/app/javascript/actionsPanel/initializeActionsPanelToggle.js b/app/javascript/actionsPanel/initializeActionsPanelToggle.js
new file mode 100644
index 000000000..83f7b1ce6
--- /dev/null
+++ b/app/javascript/actionsPanel/initializeActionsPanelToggle.js
@@ -0,0 +1,24 @@
+/** This initializes the mod actions button on the article show page (app/views/articles/show.html.erb). */
+export default function initializeActionsPanel(user, path) {
+ const modActionsMenuHTML = ``;
+ const modActionsMenuIconHTML = `
+`;
+
+ function toggleModActionsMenu() {
+ document.querySelector('.mod-actions-menu').classList.toggle('showing');
+ document.querySelector('.mod-actions-menu-btn').classList.toggle('hidden');
+ }
+
+ document.querySelector('.mod-actions-menu').innerHTML = modActionsMenuHTML;
+ // eslint-disable-next-line no-restricted-globals
+ if (!top.document.location.pathname.endsWith('/mod')) {
+ document.getElementById(
+ 'mod-actions-menu-btn-area',
+ ).innerHTML = modActionsMenuIconHTML;
+ document
+ .querySelector('.mod-actions-menu-btn')
+ .addEventListener('click', toggleModActionsMenu);
+ }
+}
diff --git a/app/javascript/packs/actionsPanel.js b/app/javascript/packs/actionsPanel.js
new file mode 100644
index 000000000..de712af6f
--- /dev/null
+++ b/app/javascript/packs/actionsPanel.js
@@ -0,0 +1,9 @@
+const loadActionsPanel = async () => {
+ const { initializeActionsPanel } = await import(
+ '../actionsPanel/actionsPanel'
+ );
+
+ initializeActionsPanel();
+};
+
+loadActionsPanel();
diff --git a/app/javascript/packs/articleModerationTools.js b/app/javascript/packs/articleModerationTools.js
new file mode 100644
index 000000000..261034ea9
--- /dev/null
+++ b/app/javascript/packs/articleModerationTools.js
@@ -0,0 +1,31 @@
+/* eslint-disable no-restricted-globals */
+/* eslint-disable no-undef */
+const user = userData();
+const { authorId: articleAuthorId, path } = document.getElementById(
+ 'article-show-container',
+).dataset;
+
+const initializeModerationsTools = async () => {
+ const { default: initializeActionsPanel } = await import(
+ '../actionsPanel/initializeActionsPanelToggle'
+ );
+ const { default: initializeFlagUserModal } = await import('./flagUserModal');
+
+ // article show page
+ if (
+ user.trusted &&
+ user.id !== articleAuthorId &&
+ !top.document.location.pathname.endsWith('/mod')
+ ) {
+ initializeActionsPanel(user, path);
+ initializeFlagUserModal(articleAuthorId);
+ // dev.to/mod
+ } else if (user.trusted && top.document.location.pathname.endsWith('/mod')) {
+ initializeActionsPanel(user, path);
+ initializeFlagUserModal(articleAuthorId);
+ }
+};
+
+initializeModerationsTools();
+/* eslint-enable no-restricted-globals */
+/* eslint-enable no-undef */
diff --git a/app/javascript/packs/articlePage.jsx b/app/javascript/packs/articlePage.jsx
index 7d22b41fc..df71a980b 100644
--- a/app/javascript/packs/articlePage.jsx
+++ b/app/javascript/packs/articlePage.jsx
@@ -6,6 +6,9 @@ const snackZone = document.getElementById('snack-zone');
render( , snackZone, snackZone.firstElementChild);
+// eslint-disable-next-line no-restricted-globals
+top.addSnackbarItem = addSnackbarItem;
+
const userDataIntervalID = setInterval(async () => {
const { user = null, userStatus } = document.body.dataset;
diff --git a/app/javascript/packs/flagUserModal.js b/app/javascript/packs/flagUserModal.js
new file mode 100644
index 000000000..6933b99e4
--- /dev/null
+++ b/app/javascript/packs/flagUserModal.js
@@ -0,0 +1,137 @@
+import { request } from '../utilities/http';
+
+export default function initializeFlagUserModal(articleAuthorId) {
+ const flagUserModalHTML = `
+
+
+
+
+
+ Thanks for keeping DEV safe. Here is what you can do to flag this user:
+
+
+
+
+
+ Make all posts by this author less visible
+
+ This author consistently posts content that violates DEV's code of conduct because it is harassing, offensive or spammy.
+
+
+
+
Report other inappropriate conduct
+
+
+
+
+
+
+`;
+
+ const toggleFlagUserModal = () => {
+ const modalContainer = document.querySelector('.flag-user-modal-container');
+ modalContainer.classList.toggle('hidden');
+
+ if (!modalContainer.classList.contains('hidden')) {
+ window.scrollTo(0, 0);
+ document.querySelector('body').style.height = '100vh';
+ document.querySelector('body').style.overflowY = 'hidden';
+ } else {
+ document.querySelector('body').style.height = 'inherit';
+ document.querySelector('body').style.overflowY = 'inherit';
+ }
+ };
+
+ const modContainer = document.getElementById('mod-container');
+ document.querySelector(
+ '.flag-user-modal-container',
+ ).innerHTML = flagUserModalHTML;
+ modContainer.addEventListener('load', () => {
+ modContainer.contentWindow.document
+ .getElementById('open-flag-user-modal')
+ .addEventListener('click', toggleFlagUserModal);
+ });
+
+ // Event listeners to Close the Modal
+ const closeModalElements = Array.from(
+ document.querySelectorAll(
+ '.crayons-modal__overlay, .modal-header-close-icon, #cancel-flag-user-action',
+ ),
+ );
+
+ closeModalElements.forEach((element) => {
+ element.addEventListener('click', toggleFlagUserModal);
+ });
+
+ document
+ .getElementById('confirm-flag-user-action')
+ .addEventListener('click', async (e) => {
+ e.preventDefault();
+ const vomitAllOption = document.getElementById('vomit-all');
+
+ if (vomitAllOption.checked) {
+ const body = JSON.stringify({
+ reactable_type: vomitAllOption.dataset.reactableType,
+ category: vomitAllOption.dataset.category,
+ reactable_id: vomitAllOption.dataset.reactableId,
+ });
+
+ try {
+ const response = await request('/reactions', {
+ method: 'POST',
+ body,
+ });
+
+ const outcome = await response.json();
+
+ if (outcome.result === 'create') {
+ // eslint-disable-next-line no-restricted-globals
+ top.addSnackbarItem({
+ message: 'All posts by this author will be less visible.',
+ addCloseButton: true,
+ });
+ } else if (outcome.result === null) {
+ // eslint-disable-next-line no-restricted-globals
+ top.addSnackbarItem({
+ message:
+ "It seems you've already reduced the vibilsity of this author's posts.",
+ addCloseButton: true,
+ });
+ } else {
+ // eslint-disable-next-line no-restricted-globals
+ top.addSnackbarItem({
+ message: `Response from server: ${JSON.stringify(outcome)}`,
+ addCloseButton: true,
+ });
+ }
+ } catch (error) {
+ // eslint-disable-next-line no-restricted-globals
+ top.addSnackbarItem({ message: error, addCloseButton: true });
+ }
+ } else {
+ // eslint-disable-next-line no-restricted-globals
+ top.addSnackbarItem({
+ message: 'No selection made!',
+ addCloseButton: true,
+ });
+ }
+ toggleFlagUserModal();
+ });
+}
diff --git a/app/models/negative_reaction.rb b/app/models/privileged_reaction.rb
similarity index 84%
rename from app/models/negative_reaction.rb
rename to app/models/privileged_reaction.rb
index 29332d43f..8bc3a3580 100644
--- a/app/models/negative_reaction.rb
+++ b/app/models/privileged_reaction.rb
@@ -1,4 +1,4 @@
-class NegativeReaction < ApplicationRecord
+class PrivilegedReaction < ApplicationRecord
resourcify
# This class exists to take advantage of Rolify for limiting authorization
# on internal reports.
diff --git a/app/models/reaction.rb b/app/models/reaction.rb
index 93c5d30fc..bab1a92d9 100644
--- a/app/models/reaction.rb
+++ b/app/models/reaction.rb
@@ -4,7 +4,7 @@ class Reaction < ApplicationRecord
SEARCH_SERIALIZER = Search::ReactionSerializer
SEARCH_CLASS = Search::Reaction
- CATEGORIES = %w[like readinglist unicorn thinking hands thumbsdown vomit].freeze
+ CATEGORIES = %w[like readinglist unicorn thinking hands thumbsup thumbsdown vomit].freeze
PUBLIC_CATEGORIES = %w[like readinglist unicorn thinking hands].freeze
REACTABLE_TYPES = %w[Comment Article User].freeze
STATUSES = %w[valid invalid confirmed archived].freeze
@@ -161,6 +161,7 @@ class Reaction < ApplicationRecord
BASE_POINTS = {
"vomit" => -50.0,
+ "thumbsup" => 5.0,
"thumbsdown" => -10.0
}.freeze
diff --git a/app/policies/user_policy.rb b/app/policies/user_policy.rb
index a9469d207..b806eeb0b 100644
--- a/app/policies/user_policy.rb
+++ b/app/policies/user_policy.rb
@@ -122,7 +122,7 @@ class UserPolicy < ApplicationPolicy
end
def moderation_routes?
- user.has_role?(:trusted) && !user.banned
+ (user.has_role?(:trusted) || minimal_admin?) && !user.banned
end
def permitted_attributes
diff --git a/app/views/articles/show.html.erb b/app/views/articles/show.html.erb
index b20c10151..82ce52013 100644
--- a/app/views/articles/show.html.erb
+++ b/app/views/articles/show.html.erb
@@ -1,7 +1,11 @@
<% title @article.title_with_query_preamble(user_signed_in?) %>
<%= render "shared/webcomponents_loader_script" %>
-<%= javascript_packs_with_chunks_tag "clipboardCopy", "webShare", "articlePage", defer: true %>
+<% if user_signed_in? %>
+ <%= javascript_packs_with_chunks_tag "clipboardCopy", "webShare", "articlePage", "articleModerationTools", defer: true %>
+<% else %>
+ <%= javascript_packs_with_chunks_tag "clipboardCopy", "webShare", "articlePage", defer: true %>
+<% end %>
<% cache("content-related-optional-scripts-#{@article.id}-#{@article.updated_at}-#{internal_navigation?}-#{user_signed_in?}", expires_in: 30.hours) do %>
<% if @article.processed_html.include? "ltag_gist-liquid-tag" %>
@@ -224,6 +228,10 @@
<% end %>
<% end %>
+
+
+
+
<% cache("article-show-scripts", expires_in: 8.hours) do %>
diff --git a/config/routes.rb b/config/routes.rb
index a0c7b0093..9a378ad4b 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -55,7 +55,7 @@ Rails.application.routes.draw do
resources :pages, only: %i[index new create edit update destroy]
resources :mods, only: %i[index update]
resources :moderator_actions, only: %i[index]
- resources :negative_reactions, only: %i[index]
+ resources :privileged_reactions, only: %i[index]
resources :permissions, only: %i[index]
resources :podcasts, only: %i[index edit update destroy] do
member do
@@ -450,6 +450,7 @@ Rails.application.routes.draw do
get "/:username/:slug/:view" => "stories#show",
:constraints => { view: /moderate/ }
get "/:username/:slug/mod" => "moderations#article"
+ get "/:username/:slug/actions_panel" => "moderations#actions_panel"
get "/:username/:slug/manage" => "articles#manage"
get "/:username/:slug/edit" => "articles#edit"
get "/:username/:slug/delete_confirm" => "articles#delete_confirm"
diff --git a/config/webpack/environment.js b/config/webpack/environment.js
index a79284b6f..aab13abb8 100644
--- a/config/webpack/environment.js
+++ b/config/webpack/environment.js
@@ -22,7 +22,7 @@ environment.splitChunks((config) => {
__dirname,
'../../app/javascript/crayons',
) /* global __dirname */,
- '@utilities': path.resolve(__dirname, '../../app/javascript/utlities'),
+ '@utilities': path.resolve(__dirname, '../../app/javascript/utilities'),
},
},
optimization: {
diff --git a/db/schema.rb b/db/schema.rb
index 83674b310..f3eb9cc83 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -1,3 +1,5 @@
+# frozen_string_literal: true
+
# This file is auto-generated from the current state of the database. Instead
# of editing this file, please use the migrations feature of Active Record to
# incrementally modify your database, and then regenerate this schema definition.
diff --git a/docs/backend/metrics.md b/docs/backend/metrics.md
index 299816647..b94083a37 100644
--- a/docs/backend/metrics.md
+++ b/docs/backend/metrics.md
@@ -7,9 +7,9 @@ title: Tracking Metrics
We track point-in-time data by sending data points to Datadog using
[Statdsd](https://github.com/DataDog/dogstatsd-ruby).
-Currently, we run a daily metric fetching job in `fetch.rake` which is called via
-the Heroku scheduler. If we wanted to do other frequencies like hourly, etc. we
-could add similar tasks.
+Currently, we run a daily metric fetching job in `fetch.rake` which is called
+via the Heroku scheduler. If we wanted to do other frequencies like hourly, etc.
+we could add similar tasks.
The rake task calls `Metrics::RecordDailyUsageWorker.perform_async`, which
performs algorithms to come up with data points to send, for example:
@@ -22,8 +22,8 @@ If you want to create a new periodic data send, follow this pattern to do so.
## Vendor-Agnostic
-While we currently are not vendor-agnostic in how we do this (Heroku/Datadog), it
-is set up in a way that could become so in the future. The main pattern is
+While we currently are not vendor-agnostic in how we do this (Heroku/Datadog),
+it is set up in a way that could become so in the future. The main pattern is
`Every x minutes/hours/etc. send aggregate data to warehouse where it can be examined on a timeseries basis`.
This could, in the future, be bundled right into the platform using an open
source timeseries database and data visualization.
diff --git a/jsconfig.json b/jsconfig.json
index 0630b5585..da867fcd6 100644
--- a/jsconfig.json
+++ b/jsconfig.json
@@ -3,7 +3,7 @@
"baseUrl": "./",
"paths": {
"@crayons/*": ["app/javascript/crayons/*"],
- "@utilities/*": ["app/javascript/utlities/*"]
+ "@utilities/*": ["app/javascript/utilities/*"]
}
},
"exclude": ["node_modules", "**/node_modules/*"]
diff --git a/spec/policies/user_policy_spec.rb b/spec/policies/user_policy_spec.rb
index 5c5ed450b..b1752232d 100644
--- a/spec/policies/user_policy_spec.rb
+++ b/spec/policies/user_policy_spec.rb
@@ -38,4 +38,16 @@ RSpec.describe UserPolicy, type: :policy do
it { is_expected.to forbid_actions(%i[moderation_routes]) }
end
+
+ context "when the user is an admin" do
+ let(:user) { build(:user, :admin) }
+
+ it { is_expected.to permit_actions(%i[moderation_routes]) }
+ end
+
+ context "when the user is a super admin" do
+ let(:user) { build(:user, :super_admin) }
+
+ it { is_expected.to permit_actions(%i[moderation_routes]) }
+ end
end
diff --git a/spec/requests/internal/negative_reactions_spec.rb b/spec/requests/internal/privileged_reactions_spec.rb
similarity index 85%
rename from spec/requests/internal/negative_reactions_spec.rb
rename to spec/requests/internal/privileged_reactions_spec.rb
index 3c4890d88..38f86ab19 100644
--- a/spec/requests/internal/negative_reactions_spec.rb
+++ b/spec/requests/internal/privileged_reactions_spec.rb
@@ -1,6 +1,6 @@
require "rails_helper"
-RSpec.describe "/internal/negative_reactions", type: :request do
+RSpec.describe "/internal/privileged_reactions", type: :request do
context "when the user is not an admin" do
let(:user) { create(:user) }
@@ -10,7 +10,7 @@ RSpec.describe "/internal/negative_reactions", type: :request do
it "blocks the request" do
expect do
- get "/internal/negative_reactions"
+ get "/internal/privileged_reactions"
end.to raise_error(Pundit::NotAuthorizedError)
end
end
@@ -38,13 +38,13 @@ RSpec.describe "/internal/negative_reactions", type: :request do
it "does not block the request" do
expect do
- get "/internal/negative_reactions"
+ get "/internal/privileged_reactions"
end.not_to raise_error
end
- describe "GETS /internal/negative_reactions" do
+ describe "GETS /internal/privileged_reactions" do
it "renders to appropriate page" do
- get "/internal/negative_reactions"
+ get "/internal/privileged_reactions"
expect(response.body).to include(CGI.escapeHTML(moderator.username)).
and include(CGI.escapeHTML(user_reaction.reactable.username)).
and include(CGI.escapeHTML(comment_reaction.reactable.user.username)).
diff --git a/spec/requests/moderations_spec.rb b/spec/requests/moderations_spec.rb
index 1c7fc13e0..2c00f861e 100644
--- a/spec/requests/moderations_spec.rb
+++ b/spec/requests/moderations_spec.rb
@@ -2,7 +2,7 @@ require "rails_helper"
RSpec.shared_examples "an elevated privilege required request" do |path|
context "when not logged-in" do
- it "does not grant acesss", proper_status: true do
+ it "does not grant access", proper_status: true do
get path
expect(response).to have_http_status(:not_found)
end
@@ -27,17 +27,18 @@ RSpec.shared_examples "an elevated privilege required request" do |path|
end
RSpec.describe "Moderations", type: :request do
- let(:user) { create(:user, :trusted) }
+ let(:trusted_user) { create(:user, :trusted) }
let(:article) { create(:article) }
let(:comment) { create(:comment, commentable: article) }
let(:dev_account) { create(:user) }
it_behaves_like "an elevated privilege required request", "/username/random-article/mod"
it_behaves_like "an elevated privilege required request", "/username/comment/1/mod"
+ it_behaves_like "an elevated privilege required request", "/username/random-article/actions_panel"
context "when user is trusted" do
before do
- sign_in user
+ sign_in trusted_user
allow(User).to receive(:dev_account).and_return(dev_account)
end
@@ -52,7 +53,7 @@ RSpec.describe "Moderations", type: :request do
end
it "grants access to /mod index" do
- create(:rating_vote, article: article, user: user)
+ create(:rating_vote, article: article, user: trusted_user)
get "/mod"
expect(response).to have_http_status(:ok)
end
@@ -69,12 +70,69 @@ RSpec.describe "Moderations", type: :request do
expect(response.body).to include("#" + article.tags.first.name.titleize)
end
- it "returns not found for inapprpriate tags" do
+ it "returns not found for inappropriate tags" do
expect { get "/mod/dsdsdsweweedsdseweww" }.to raise_exception(ActiveRecord::RecordNotFound)
end
it "renders not_found when an article can't be found" do
- expect { get "/#{user.username}/dsdsdsweweedsdseweww/mod/" }.to raise_exception(ActiveRecord::RecordNotFound)
+ expect { get "/#{trusted_user.username}/dsdsdsweweedsdseweww/mod/" }.to raise_exception(ActiveRecord::RecordNotFound)
+ end
+ end
+
+ describe "actions_panel" do
+ context "when the user is a tag moderator" do
+ it "shows the option to remove the tag when the article has the tag" do
+ tag_mod = create(:user, :tag_moderator)
+ tag_mod.add_role :trusted
+ tag = tag_mod.roles.find_by(name: "tag_moderator").resource
+ article = create(:article, tags: tag)
+ sign_in tag_mod
+
+ get "#{article.path}/actions_panel"
+ expect(response.body).to include "circle centered-icon adjustment-icon subtract"
+ end
+ end
+
+ it "shows the option to add the tag when the article has the tag" do
+ tag_mod = create(:user, :tag_moderator)
+ tag_mod.add_role :trusted
+ article = create(:article, tags: "javascript, cool, beans")
+ sign_in tag_mod
+
+ get "#{article.path}/actions_panel"
+ expect(response.body).to include "circle centered-icon adjustment-icon plus"
+ end
+ end
+
+ context "when the user is trusted" do
+ before do
+ sign_in trusted_user
+ get "#{article.path}/actions_panel"
+ end
+
+ it "does not show the adjust tags options" do
+ expect(response.body).not_to include "other-things-btn adjust-tags"
+ end
+
+ it "shows the experience level option" do
+ expect(response.body).to include "other-things-btn set-experience"
+ end
+ end
+
+ context "when the user is an admin" do
+ before do
+ admin = create(:user, :admin)
+ sign_in admin
+ article = create(:article, tags: "javascript, cool, beans")
+ get "#{article.path}/actions_panel"
+ end
+
+ it "shows the admin add tag option if the article has room for a tag" do
+ expect(response.body).to include "admin-add-tag"
+ end
+
+ it "shows the option to remove tags" do
+ expect(response.body).to include "circle centered-icon adjustment-icon subtract"
end
end
end