[deploy] New Feature: Mod Actions Panel (#7777)

* db

* Add an iframe to host mod actions menu

This iframe is probably the fastest way to get a working mod actions
menu without rewriting existing code. We may eventually re-write this,
but based on Ben's recommendation we are going to go with an iframe
because it gets us a working feature quickly and it seems to be serving
us well in other parts of the application.

* Add the mod actions menu button to articles

When the user is "trusted" they will see a small badge on the lower
right-hand side of the screen. Clicking the icon reveals the mod actions
menu.

* Add header to actions panel (#7395)

* Update iframe border

* Add 'Moderate Post' header and button

* Use inline SVG over <img src='svg'>

* Use Crayons variables for units

* Use flexbox to center chevron

* Add accidentally deleted positioning

* Add chevron-right.svg

* Use a partial for actions panel and revert /mod page

* Load actions panel in iframe and not /mod

* Style UpVote, DownVote and Vomit buttons (#7421)

* Add a iframe to host mod actions menu

* Up and Downvote Icons

* Starting with vomit button

* Completed UI; interactions pending

* Incorporated Andy changes; making mod action-panel

* space reactions away from header

* UI tweaks and corrections 1

* Fixing styles to match crayons

* choose pure white color css variable

* Add thumbs-up emoji; address Lisa comments

* Trying to fix reactions interactions

* remove clearThumbReactions function

Co-authored-by: jacobherrington <jacobherringtondeveloper@gmail.com>

* Add bottom layout for actions panel (#7501)

* WIP for Lisa

* Finalize mod actions bottom section

* Use showing instead of hidden for transition

* Add new optimized SVGs

* Fix weird height issues for actions panel

* Move hover styles below the cascade, yo

* Use margin-top for sticky footer over height: 100%

* Move initialize actions panel button to Webpack (#7506)

* Move actions panel JS code to webpack

* Refactor and use destructuring

Thanks @nickytonline!

Co-Authored-By: Nick Taylor <nick@iamdeveloper.com>

* Rename file

Co-authored-by: Nick Taylor <nick@iamdeveloper.com>

* Configure Experience Level options for Mod Action Panel (#7507)

* Add a iframe to host mod actions menu

* Click interactions (no toggle of dropdowns yet)

Co-authored-by: jacobherrington <jacobherringtondeveloper@gmail.com>

* Fix the iframe's path (#7509)

* Toggle the 'Set Experience Level' menu on button-click (#7529)

* Add a iframe to host mod actions menu

* Add set-exp dropdown functionality

* WIP: making exp level updates async

* Completes async implementation

* Fix controller for "actions_panel" and "/mod"

Co-authored-by: jacobherrington <jacobherringtondeveloper@gmail.com>

* Add close button interaction (#7552)

* Rename actions panel pack file

* Add close button toggle to actions panel

* DB update

* Move more code to Webpack and add conditional tag adjustments section (#7652)

* Move JS code to Webpack

* Clean up a few style issues

* Add styling for tag adjustments

* Add active/inactive functionality

* Add conditional rendering for tag adjustments section

* Add missing variable oops (#7673)

* update with upstream

* Add missing subtract svg (#7695)

* Create 'Flag User' modal (#7626)

* Add a iframe to host mod actions menu

* Starting task

* Phase 1

* Fix "Close Modal" icon

* Implement open modal

* Working on all "Close Modal" actions

* Almost-complete implementation

* Remove Abuse Report snackbar

* Delete unneeded JS code

* Remove "How does this work?" links

* Link to Community Moderation page

* Addressing code review corrections

* Update api call to use "request" method

* Make else path clearer in api call

Co-authored-by: jacobherrington <jacobherringtondeveloper@gmail.com>

* Mod Actions Panel: Prevent nonsensical reaction pairs (#7699)

* Still implementing

* Completed Implementation

* Address code review feednack; refactor "create" method

* Move scss variables to native-css (for ModPanel code only)

* Correct missing native css

* update to native-css again

* More sccs -> native-css fixes

* Adjust ModPanel Button position on mobile (#7744)

* Adjusted ModPanel position on mobile

* Better adjustment

* Fix phantom schema.rb changes

* Fix phantom schema.rb changes

* Finalize tag adjustments (#7740)

* Finalize tag adjustments and flow

* Fix logic for rendering adjust tags options

* Clean up alert response

* Rename user to trusted user

* Use new CSS variables

* Add/remove tag on article's tag section after adjusting

* Fix logic for admin tag input and some other niceties

* Final bug fixes mod panel (#7772)

* Use correct boolean statement

* Clear the value of the textarea not div

* Use the correct attribute

* Use actions panel for /mod page (#7775)

* Fix loading logic with tag adjust button conditional render

* Link to tag moderation for adjust tags section

* Use top over window.parent

Thanks @nickytonline!

Co-authored-by: Nick Taylor <nick@iamdeveloper.com>

* Use Webpack alias to import

Co-authored-by: Nick Taylor <nick@iamdeveloper.com>

* Use place-items shorthand over align-items + justify-content

Thanks @nickytonline!

Co-authored-by: Nick Taylor <nick@iamdeveloper.com>

* Fix typo for utilities

* Add new line to end of file

* Disable linter for use for top function

* Use align-items and justify-content center

* Update SVGs to spec

* Remove unnecessary height and use spacing units for open button

* Remove accidentally added comment

* Error handling when selecting reactions; optimize "reactions.each" loop

* Add comment about clearing innerHTML

* Revert schema back to master

* Use each since there should only be two reactions

* Fix typo oops

* Add missing height oops

* Return if there are no reactions to be destroyed

* Center path with GUI tool shrug

* chose more appropriate name for JS pack; rewrote api calls using async-await

* Rename variables and methods and check categories before clearing

* Use addSnackbarItem instead of hardcoded version

* Place articleModerationTools pack behind user_signed_in

* Correct omission

* Implement dynamic imports for ModPanel tools

* Implemented async/await; added snackbar message for null response use-case

* Fix the "/article/mod" page; make sure all reactions, vomit-user and exp-level working as expected

* Update language to reflect new UI

* Close the modal after the "Confirm Action" button is clicked

* Change text for Spam/Abuse button

* Fix schema to use with master

* Update schema to master again

* Use crayons button for actions panel button

* UI tweak to play nicer with site themes

* Make actions panel work for dev.to/mod

* Add snackbar for reactions

* Update how does this work links

* Add "thumbsup" reactions to logging

* Add thumbsup to categories

* Add external link icon

* Changes to reflect "Privileged" and not "Negative" reactions; spec changes pending

* Updated instance variable

* Starting to update the specs

* Update "thumbsup" points from 10 to 5

* Still working on both requests

* Embracing an easier implemantation (thanks Andy!)

* Add tests for actions panel

* Add some basic tests for actions panel

* Change to flag to admins

* Indicate that 'Reason for Tag adjust' is required

Co-authored-by: Mac Siri <krairit.siri@gmail.com>

* Rename negative_reactions to privileged

* Update rating level to use correct values

* Use a different display value than backend value

* Don't show adjust tags for trusted users

* Add specs for moderation_routes for admin and super_admin

Co-authored-by: Arit Amana <msarit@gmail.com>
Co-authored-by: jacobherrington <jacobherringtondeveloper@gmail.com>
Co-authored-by: Arit Amana <32520970+msarit@users.noreply.github.com>
Co-authored-by: Nick Taylor <nick@iamdeveloper.com>
Co-authored-by: Mac Siri <krairit.siri@gmail.com>
This commit is contained in:
Andy Zhao 2020-06-08 13:24:18 -04:00 committed by GitHub
parent 6d684f018f
commit 885e042d31
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
43 changed files with 1924 additions and 153 deletions

View file

@ -0,0 +1,3 @@
<svg viewBox="0 0 24 24" width="24" height="24" xmlns="http://www.w3.org/2000/svg">
<path d="M20.562 3.983h-14c-1.54 0-2.502 1.666-1.732 3 .357.619 1.017 1 1.732 1h14v13a1 1 0 01-1 1h-13a4 4 0 01-4-4v-12a4 4 0 014-4h13a1 1 0 011 1v1zm-16 14a2 2 0 002 2h12v-10h-12a3.984 3.984 0 01-2-.535v8.535zm15-11h-13a1 1 0 010-2h13v2z"/>
</svg>

After

Width:  |  Height:  |  Size: 337 B

View file

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M13.172 12L8.22198 7.04999L9.63598 5.63599L16 12L9.63598 18.364L8.22198 16.95L13.172 12Z"/>
</svg>

After

Width:  |  Height:  |  Size: 192 B

View file

@ -0,0 +1 @@
<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M6 6L0 0h12L6 6z"/></svg>

After

Width:  |  Height:  |  Size: 118 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

View file

@ -0,0 +1 @@
<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M3.783 2.826L12 1l8.217 1.826a1 1 0 01.783.976v9.987a6 6 0 01-2.672 4.992L12 23l-6.328-4.219A6 6 0 013 13.79V3.802a1 1 0 01.783-.976zM13 10V5l-5 7h3v5l5-7h-3z"/></svg>

After

Width:  |  Height:  |  Size: 260 B

View file

@ -0,0 +1,3 @@
<svg fill="none" height="24" width="24" xmlns="http://www.w3.org/2000/svg">
<path d="M11 11V5h2v6h6v2h-6v6h-2v-6H5v-2z" fill="#08090a"/>
</svg>

After

Width:  |  Height:  |  Size: 148 B

View file

@ -0,0 +1,3 @@
<svg width="14" height="2" xmlns="http://www.w3.org/2000/svg">
<path d="M0 0h14v2H0V0z"/>
</svg>

After

Width:  |  Height:  |  Size: 101 B

View file

@ -55,11 +55,6 @@ function addRelevantButtonsToArticle(user) {
);
}
document.getElementById('action-space').innerHTML = actions.join('');
} else if (user.trusted) {
document.getElementById('action-space').innerHTML =
'<a href="' +
articleContainer.dataset.path +
'/mod" rel="nofollow">MODERATE <span class="post-word">POST</span></a>';
}
}
}
@ -99,6 +94,20 @@ function addRelevantButtonsToComments(user) {
function initializeBaseUserData() {
const user = userData();
const userProfileLinkHTML =
'<a href="/' +
user.username +
'" id="first-nav-link" class="crayons-link crayons-link--block"><div>' +
'<span class="fw-medium block">' +
user.name +
'</span>' +
'<small class="fs-s color-base-50">@' +
user.username +
'</small>' +
'</div></a>';
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;

View file

@ -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;

View file

@ -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;
}

View file

@ -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" },

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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 = `
<body>
<div class="mod-actions-menu showing">
<button class="close-actions-panel circle centered-icon" type="button" title="Close moderator actions panel">
</button>
</div>
<div id="mod-actions-menu-btn-area">
<div class="mod-actions-menu-btn crayons-btn crayons-btn--icon-rounded crayons-btn--s hidden">
</div>
</div>
</body>
`;
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 = `
<body>
<div id="page-content">
<div id="page-content-inner">
</div>
</div>
</body>
`;
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 = `
<button class="reaction-button" data-reactable-id="1" data-reactable-type="Article" data-category="thumbsup">
</button>
<button class="reaction-button" data-reactable-id="1" data-reactable-type="Article" data-category="thumbsdown">
</button>
<button class="reaction-vomit-button" data-reactable-id="1" data-reactable-type="Article" data-category="vomit">
</button>
`;
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 = `
<a href="/t/${tagName}" class="tag">${tagName}</a>
<button class="adjustable-tag" data-adjustment-type="subtract" data-tag-name="${tagName}">
#${tagName}
</button>
<form id="adjustment-reason-container" class="adjustment-reason-container hidden">
<textarea class="crayons-textfield" placeholder="Reason for tag adjustment (required)" id="tag-adjustment-reason" required></textarea>
<button class="crayons-btn" id="tag-adjust-submit" type="submit">Submit</button>
</form>
`;
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 = `
<div class="add-tag-container">
<input id="admin-add-tag" class="crayons-textfield" type="text" placeholder="Add a tag" data-article-id="1" data-adjustment-type="plus">
</div>
<a href="/t/${tagName}" class="tag">${tagName}</a>
<button class="adjustable-tag" data-adjustment-type="subtract" data-tag-name="${tagName}">
#${tagName}
</button>
<button class="adjustable-tag" data-adjustment-type="subtract" data-tag-name="ruby">
#ruby
</button>
<form id="adjustment-reason-container" class="adjustment-reason-container hidden">
<textarea class="crayons-textfield" placeholder="Reason for tag adjustment" id="tag-adjustment-reason" required></textarea>
<button class="crayons-btn" id="tag-adjust-submit" type="submit">Submit</button>
</form>
`;
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 */

View file

@ -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 = `
<div class="mod-actions-menu"></div>
<div id="mod-actions-menu-btn-area"></div>
`;
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();
});
});
});

View file

@ -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();
}

View file

@ -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 = `<iframe id="mod-container" src=${path}/actions_panel></iframe>`;
const modActionsMenuIconHTML = `<div class="mod-actions-menu-btn crayons-btn crayons-btn--icon-rounded crayons-btn--s">
<svg xmlns="http://www.w3.org/2000/svg" width="54px" height="54px" viewBox="-8 -8 40 40" class="crayons-icon actions-menu-svg" role="img" aria-labelledby=""><title id="">Moderation</title><path d="M3.783 2.826L12 1l8.217 1.826a1 1 0 01.783.976v9.987a6 6 0 01-2.672 4.992L12 23l-6.328-4.219A6 6 0 013 13.79V3.802a1 1 0 01.783-.976zM5 4.604v9.185a4 4 0 001.781 3.328L12 20.597l5.219-3.48A4 4 0 0019 13.79V4.604L12 3.05 5 4.604zM13 10h3l-5 7v-5H8l5-7v5z"></path></svg>
</div>
`;
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);
}
}

View file

@ -0,0 +1,9 @@
const loadActionsPanel = async () => {
const { initializeActionsPanel } = await import(
'../actionsPanel/actionsPanel'
);
initializeActionsPanel();
};
loadActionsPanel();

View file

@ -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 */

View file

@ -6,6 +6,9 @@ const snackZone = document.getElementById('snack-zone');
render(<Snackbar lifespan="3" />, snackZone, snackZone.firstElementChild);
// eslint-disable-next-line no-restricted-globals
top.addSnackbarItem = addSnackbarItem;
const userDataIntervalID = setInterval(async () => {
const { user = null, userStatus } = document.body.dataset;

View file

@ -0,0 +1,137 @@
import { request } from '../utilities/http';
export default function initializeFlagUserModal(articleAuthorId) {
const flagUserModalHTML = `
<div class="crayons-modal crayons-modal--s absolute flag-user-modal">
<div class="crayons-modal__box">
<header class="crayons-modal__box__header flag-user-modal-header">
<h2>Flag User</h2>
<button type="button" class="crayons-btn crayons-btn--icon crayons-btn--ghost modal-header-close-icon">
<svg width="24" height="24" viewBox="0 0 24 24" class="crayons-icon" xmlns="http://www.w3.org/2000/svg">
<path d="M12 10.586l4.95-4.95 1.414 1.414-4.95 4.95 4.95 4.95-1.414 1.414-4.95-4.95-4.95 4.95-1.414-1.414 4.95-4.95-4.95-4.95L7.05 5.636l4.95 4.95z" />
</svg>
</button>
</header>
<div class="crayons-modal__box__body flag-user-modal-body">
<span>
Thanks for keeping DEV safe. Here is what you can do to flag this user:
</span>
<div class="crayons-fields">
<div class="crayons-field crayons-field--radio">
<input
type="radio"
id="vomit-all"
name="flag-user"
class="crayons-radio"
data-reactable-id="${articleAuthorId}"
data-category="vomit"
data-reactable-type="User">
<label for="vomit-all" class="crayons-field__label">
Make all posts by this author less visible
<p class="crayons-field__description">
This author consistently posts content that violates DEV's code of conduct because it is harassing, offensive or spammy.
</p>
</label>
</div>
<a href="/report-abuse?url=${document.location}" class="fs-base abuse-report-link">Report other inappropriate conduct</a>
</div>
<div class="buttons-container">
<a href="#" class="crayons-btn crayons-btn--danger" id="confirm-flag-user-action">Confirm action</a>
<a href="#" class="crayons-btn crayons-btn--secondary" id="cancel-flag-user-action">Cancel</a>
</div>
</div>
</div>
<div class="crayons-modal__overlay"></div>
</div>
`;
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();
});
}

View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -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 %>
<div class="mod-actions-menu"></div>
<div id="mod-actions-menu-btn-area"></div>
<div class="flag-user-modal-container hidden"></div>
<% cache("article-show-scripts", expires_in: 8.hours) do %>
<script async>
<%# we consider these scripts safe for embedding as they come from our code %>

View file

@ -49,7 +49,7 @@
<% if params[:state] == "potential" %>
<td>
<%= form_with model: [:internal, mod], url: internal_mod_path(mod.id), method: :patch, local: true do |f| %>
<%= f.submit "Make Trusted Mod", class: "btn btn-light" %>
<%= f.submit "Make Trusted Mod", class: "btn btn-light js-add-to-mod-channel" %>
<% end %>
</td>
<% end %>

View file

@ -2,14 +2,14 @@
<div class="col">
</div>
<div class="col">
<%= search_form_for @q, url: internal_negative_reactions_path, class: "form-inline justify-content-end" do |f| %>
<%= search_form_for @q, url: internal_privileged_reactions_path, class: "form-inline justify-content-end" do |f| %>
<%= f.label :user_username_cont, "User", class: "sr-only" %>
<%= f.search_field :user_username_cont, placeholder: "User", class: "form-control mx-3" %>
<%= f.select(
:category_eq,
options_for_select(["", "vomit", "thumbsdown"], @q.category_eq),
options_for_select(["", "thumbsup", "thumbsdown", "vomit"], @q.category_eq),
{},
class: "custom-select mx-3",
) %>
@ -19,7 +19,7 @@
</div>
</div>
<%= paginate @negative_reactions %>
<%= paginate @privileged_reactions %>
<table class="table">
<thead>
@ -33,7 +33,7 @@
</tr>
</thead>
<tbody>
<% @negative_reactions.each do |reaction| %>
<% @privileged_reactions.each do |reaction| %>
<tr>
<td><%= reaction.id %></td>
<td><%= link_to reaction.user.username, internal_user_path(reaction.user_id) %></td>
@ -54,4 +54,4 @@
</tbody>
</table>
<%= paginate @negative_reactions %>
<%= paginate @privileged_reactions %>

View file

@ -0,0 +1,161 @@
<% title "Moderating " + @moderatable.title %>
<% cache(cache_key_heroku_slug("mod-styling")) do %>
<style>
<%= Rails.application.assets["moderators.css"].to_s.html_safe %>
</style>
<% end %>
<%= javascript_packs_with_chunks_tag "actionsPanel", defer: true %>
<div class="container mod-container">
<header class="top-header">
<h1>Moderate Post</h1>
<h2>Rate the quality of this post</h2>
<button class="close-actions-panel circle centered-icon" type="button" title="Close moderator actions panel">
<%= inline_svg_tag("chevron-right.svg", aria: false) %>
</button>
</header>
<div class="reactions-container">
<div class="thumb-reactions-container">
<button class="reaction-button <%= Reaction.cached_any_reactions_for?(@moderatable, current_user, "thumbsup") ? "reacted" : "" %>"
data-reactable-id="<%= @moderatable.id %>"
data-category="thumbsup"
data-reactable-type="<%= @moderatable.class.name %>">
<div class="reaction-button-circle">
<%= image_tag("emoji/emoji-one-thumbs-up-gray.png", alt: "Thumbs up emoji", class: "emoji-grey") %>
<img class="reacted-emoji" src="<%= asset_path("emoji/emoji-one-thumbs-up.png") %>" alt="Thumbs up emoji" />
<%= inline_svg_tag("checkmark.svg", aria: true, class: "crayons-icon reaction-checkmark", title: "Checkmark") %>
</div>
<span class="vote-text">High Quality</span>
</button>
<button class="reaction-button <%= Reaction.cached_any_reactions_for?(@moderatable, current_user, "thumbsdown") ? "reacted" : "" %>"
data-reactable-id="<%= @moderatable.id %>"
data-category="thumbsdown"
data-reactable-type="<%= @moderatable.class.name %>">
<div class="reaction-button-circle">
<%= image_tag("emoji/emoji-one-thumbs-down-gray.png", alt: "Thumbs down emoji", class: "emoji-grey") %>
<img class="reacted-emoji downvote" src="<%= asset_path("emoji/emoji-one-thumbs-down.png") %>" alt="Thumbs down emoji" />
<%= inline_svg_tag("checkmark.svg", aria: true, class: "crayons-icon reaction-checkmark", title: "Checkmark") %>
</div>
<span class="vote-text">Low Quality</span>
</button>
</div>
<button class="reaction-vomit-button <%= Reaction.cached_any_reactions_for?(@moderatable, current_user, "vomit") ? "reacted" : "" %>"
data-reactable-id="<%= @moderatable.id %>"
data-category="vomit"
data-reactable-type="<%= @moderatable.class.name %>">
<%= image_tag("emoji/emoji-one-nausea-face-gray.png", alt: "Nausea face emoji", class: "emoji-grey") %>
<img class="reacted-emoji" src="<%= asset_path("emoji/emoji-one-nausea-face.png") %>" alt="Nausea down emoji" />
<span>Flag to Admins</span>
<%= inline_svg_tag("checkmark.svg", aria: true, class: "crayons-icon vomit-checkmark", title: "Checkmark") %>
</button>
<a href="<%= URL.url %>/community-moderation#mod-voting" target="_blank" rel="noopener">
<span class="how-this-works-section">
How does this work?
<%= inline_svg_tag("external-link-logo.svg", aria: true) %>
</span>
</a>
</div>
<div class="other-things-container">
<header class="other-things">
<h1>Other things you can do</h1>
</header>
<% if current_user.any_admin? || @should_show_adjust_tags %>
<button class="other-things-btn adjust-tags" type="button" data-other-things-type="adjust-tags">
<div class="label-wrapper">
<div class="icon circle centered-icon">
<%= inline_svg_tag("mod-filled.svg", aria: true, title: "Adjust tags") %>
</div>
<header>
<h2>Adjust tags</h2>
<h3>Add or remove tags</h3>
</header>
</div>
<div class="toggle-chevron-container">
<%= inline_svg_tag("chevron-toggle.svg", aria: false) %>
</div>
</button>
<div class="adjust-tags-options dropdown-options hidden">
<% if current_user.any_admin? %>
<% if @moderatable.tag_list.size < 4 %>
<div class="add-tag-container">
<input id="admin-add-tag" class="crayons-textfield" type="text" placeholder="Add a tag" data-article-id="<%= @moderatable.id %>" data-adjustment-type="plus">
</div>
<% end %>
<% @moderatable.tags.each do |tag| %>
<button class="adjustable-tag" type="button" data-adjustment-type="subtract" data-tag-name="<%= tag.name %>" data-article-id="<%= @moderatable.id %>">
<span class="num-sign">#</span><%= tag.name %>
<div class="circle centered-icon adjustment-icon subtract">
<%= inline_svg_tag("subtract.svg", aria: false) %>
</div>
</button>
<% end %>
<% else %>
<% @tag_moderator_tags.each do |tag| %>
<% subtract_or_plus = @moderatable.tag_list.include?(tag.name) ? "subtract" : "plus" %>
<button class="adjustable-tag" type="button" data-adjustment-type="<%= subtract_or_plus %>" data-tag-name="<%= tag.name %>" data-article-id="<%= @moderatable.id %>">
<span class="num-sign">#</span><%= tag.name %>
<div class="circle centered-icon adjustment-icon <%= subtract_or_plus %>">
<%= inline_svg_tag("#{subtract_or_plus}.svg", aria: false) %>
</div>
</button>
<% end %>
<% end %>
<form id="adjustment-reason-container" class="adjustment-reason-container hidden">
<textarea class="crayons-textfield" placeholder="Reason for tag adjustment" id="tag-adjustment-reason" required></textarea>
<button class="crayons-btn" id="tag-adjust-submit" type="submit">Submit</button>
</form>
<a href="<%= URL.url %>/tag-moderation#tag-adjustments" target="_blank" rel="noopener">
<span class="how-this-works-section">
How does this work?
<%= inline_svg_tag("external-link-logo.svg", aria: true) %>
</span>
</a>
</div>
<% end %>
<% @rating_vote = RatingVote.where(article_id: @moderatable.id, user_id: current_user.id).first %>
<button class="other-things-btn set-experience" type="button" data-other-things-type="set-experience">
<div class="label-wrapper">
<div class="icon circle centered-icon">
<%= inline_svg_tag("book.svg", aria: false) %>
</div>
<header>
<h2>Set experience level</h2>
<h3>Curate the most appropriate audience for this post</h3>
</header>
</div>
<div class="toggle-chevron-container">
<%= inline_svg_tag("chevron-toggle.svg", aria: false) %>
</div>
</button>
<div class="set-experience-options dropdown-options hidden">
<% rating_hash = { "Expert" => [10, 5], "Advanced" => [8, 4], "Mid-level" => [5, 3], "Beginner" => [3, 2], "Novice" => [1, 1] } %>
<% rating_hash.each do |rating_name, rating_level| %>
<button
value="<%= rating_level[0] %>"
name="rating_vote_<%= rating_level[0] %>"
id="js__rating__vote__<%= rating_level[0] %>"
class="level-rating-button <%= " selected" if @rating_vote&.rating == rating_level[0].to_f %>"
data-user-id="<%= current_user.id %>"
data-article-id="<%= @moderatable.id %>"
data-group="experience_level">
<div class="level-rating-content">
<span class="level-rating-number"><%= rating_level[1] %></span>
<span class="level-rating-text"><%= rating_name %></span>
</div>
<%= inline_svg_tag("checkmark.svg", aria: true, class: "crayons-icon", title: "Checkmark") %>
</button>
<% end %>
<a href="<%= URL.url %>/community-moderation#setting-experience-level" target="_blank" rel="noopener">
<span class="how-this-works-section">
How does this work?
<%= inline_svg_tag("external-link-logo.svg", aria: true) %>
</span>
</a>
</div>
</div>
<div class="bottom-actions">
<button id="open-flag-user-modal" type="button">Flag user</button>
</div>
</div>

View file

@ -78,10 +78,11 @@
els[i].onclick = function(e) {
let path = e.target.dataset.path
let el = document.getElementById('quick-mod-' + i)
// if iframe is opened/loaded, clear the innerHTML
if (el.innerHTML.length > 10) {
el.innerHTML = ''
} else {
el.innerHTML = '<div class="mod-index-row-iframes"><iframe src="' + path + '"></iframe><iframe src="' + path + '/mod"></iframe></div>'
el.innerHTML = '<div class="mod-index-row-iframes"><iframe id="quick-mod-article" src="' + path + '"></iframe><iframe class="actions-panel" src="' + path + '/actions_panel"></iframe></div>'
}
}
}

View file

@ -10,9 +10,8 @@
<h2><a href="<%= @moderatable.path %>" rel="nofollow"><%= @moderatable.title %></a></h2>
<h3><a href="<%= @moderatable.user.path %>">@<%= @moderatable.user.username %></a></h3>
<% if @moderatable.class.name == "Article" &&
(Tag.where(requires_approval: true).pluck(:name) & @moderatable.decorate.cached_tag_list_array).any? &&
(current_user.any_admin? || @tag_moderator_tags.any? { |tag| @moderatable.tag_list.include?(tag.name) })
%>
(Tag.where(requires_approval: true).pluck(:name) & @moderatable.decorate.cached_tag_list_array).any? &&
(current_user.any_admin? || @tag_moderator_tags.any? { |tag| @moderatable.tag_list.include?(tag.name) }) %>
<%= form_tag "/article_approvals", method: "post" do %>
<%= hidden_field_tag(:id, @moderatable.id) %>
<% if @moderatable.approved %>
@ -26,20 +25,42 @@
<hr />
<% end %>
<button class="reaction-button <%= Reaction.cached_any_reactions_for?(@moderatable, current_user, "thumbsdown") ? "reacted" : "" %>"
<div style="display: flex; justify-content: space-around;">
<button class="reaction-button <%= Reaction.cached_any_reactions_for?(@moderatable, current_user, "thumbsup") ? "reacted" : "" %>"
data-reactable-id="<%= @moderatable.id %>"
data-category="thumbsdown"
data-category="thumbsup"
data-reactable-type="<%= @moderatable.class.name %>">
<%= image_tag("emoji/emoji-one-thumbs-down-gray.png", alt: "Thumbs down emoji") %>
<img class="reacted-emoji" src="<%= asset_path("emoji/emoji-one-thumbs-down.png") %>" alt="Thumbs down emoji" />
</button>
<button class="reaction-button <%= Reaction.cached_any_reactions_for?(@moderatable, current_user, "vomit") ? "reacted" : "" %>"
data-reactable-id="<%= @moderatable.id %>"
data-category="vomit"
data-reactable-type="<%= @moderatable.class.name %>">
<%= image_tag("emoji/emoji-one-nausea-face-gray.png", alt: "Nausea face emoji") %>
<img class="reacted-emoji" src="<%= asset_path("emoji/emoji-one-nausea-face.png") %>" alt="Nausea down emoji" />
<div class="reaction-button-circle">
<%= image_tag("emoji/emoji-one-thumbs-up-gray.png", alt: "Thumbs up emoji", class: "emoji-grey") %>
<img class="reacted-emoji" src="<%= asset_path("emoji/emoji-one-thumbs-up.png") %>" alt="Thumbs up emoji" />
<%= inline_svg_tag("checkmark.svg", aria: true, class: "crayons-icon reaction-checkmark", title: "Checkmark") %>
</div>
<span class="vote-text">High Quality</span>
</button>
<button class="reaction-button <%= Reaction.cached_any_reactions_for?(@moderatable, current_user, "thumbsdown") ? "reacted" : "" %>"
data-reactable-id="<%= @moderatable.id %>"
data-category="thumbsdown"
data-reactable-type="<%= @moderatable.class.name %>">
<div class="reaction-button-circle">
<%= image_tag("emoji/emoji-one-thumbs-down-gray.png", alt: "Thumbs down emoji", class: "emoji-grey") %>
<img class="reacted-emoji downvote" src="<%= asset_path("emoji/emoji-one-thumbs-down.png") %>" alt="Thumbs down emoji" />
<%= inline_svg_tag("checkmark.svg", aria: true, class: "crayons-icon reaction-checkmark", title: "Checkmark") %>
</div>
<span class="vote-text">Low Quality</span>
</button>
<button class="reaction-button <%= Reaction.cached_any_reactions_for?(@moderatable, current_user, "vomit") ? "reacted" : "" %>"
data-reactable-id="<%= @moderatable.id %>"
data-category="vomit"
data-reactable-type="<%= @moderatable.class.name %>">
<div class="reaction-button-circle">
<%= image_tag("emoji/emoji-one-nausea-face-gray.png", alt: "Nausea face emoji", class: "emoji-grey") %>
<img class="reacted-emoji" src="<%= asset_path("emoji/emoji-one-nausea-face.png") %>" alt="Nausea down emoji" />
<%= inline_svg_tag("checkmark.svg", aria: true, class: "crayons-icon reaction-checkmark", title: "Checkmark") %>
</div>
<span class="vote-text">Mark As Spam</span>
</button>
</div>
<% if current_user.has_role?(:super_admin) && @moderatable.class.name == "Article" %>
<h3> <a href="<%= @moderatable.path %>/edit">Edit Post</a> |
<a href="/internal/articles/<%= @moderatable.id %>" data-no-instant>Internal:Article</a> |
@ -49,22 +70,27 @@
<a href="/admin/users/<%= @moderatable.user_id %>" data-no-instant>Admin:User</a></h3>
<% end %>
<p>
<b style="font-size:1.3em">All negative reactions are private.</b>
<b style="font-size:1em">All negative reactions are private.</b>
</p>
<p>
Use <b>thumbsdown</b> to move this content "down" for any reason (quality, usefulness, etc.).
Use <b>High Quality</b> to move this content "up" for any reason (quality, usefulness, etc.).
<br />
Use <b>Low Quality</b> to move this content "down" for any reason (quality, usefulness, etc.).
<br />
Use <b>Mark As Spam</b> for code of conduct violations (harassment, being a jerk, spam, etc.).
</p>
<p>
Use <b>vomit</b> for code of conduct violations (harassment, being a jerk, spam, etc.).
</p>
<div class="tag-mod-form">
<div class="tag-mod-form" style="display: flex; flex-direction: column; align-items: center;">
<h2>Flag <a href="<%= @moderatable.user.path %>"><%= @moderatable.user.username %></a> as Abusive</h2>
<button class="reaction-button <%= Reaction.cached_any_reactions_for?(@moderatable.user, current_user, "vomit") ? "reacted" : "" %>"
<button class="reaction-button <%= Reaction.cached_any_reactions_for?(@moderatable, current_user, "vomit") ? "reacted" : "" %>"
data-reactable-id="<%= @moderatable.user.id %>"
data-category="vomit"
data-reactable-type="<%= @moderatable.user.class.name %>">
<%= image_tag("emoji/emoji-one-nausea-face-gray.png", alt: "Nausea face emoji") %>
<img class="reacted-emoji" src="<%= asset_path("emoji/emoji-one-nausea-face.png") %>" alt="Nausea down emoji" />
<div class="reaction-button-circle">
<%= image_tag("emoji/emoji-one-nausea-face-gray.png", alt: "Nausea face emoji", class: "emoji-grey") %>
<img class="reacted-emoji" src="<%= asset_path("emoji/emoji-one-nausea-face.png") %>" alt="Nausea down emoji" />
<%= inline_svg_tag("checkmark.svg", aria: true, class: "crayons-icon reaction-checkmark", title: "Checkmark") %>
</div>
<span class="vote-text">Flag As Abusive</span>
</button>
<p>
This is the equivalent of vomiting on <b>all</b> of this user's articles.
@ -136,20 +162,25 @@
<% if @moderatable.class.name == "Article" %>
<div class="tag-mod-form">
<% @rating_vote = RatingVote.where(article_id: @moderatable.id, user_id: current_user.id).first %>
<%= form_for(RatingVote.new) do |f| %>
<h2>Experience Level of Post</h2>
<%= f.hidden_field :article_id, value: @moderatable.id %>
<%= f.hidden_field :group, value: "experience_level" %>
<button value="1" name="rating_vote[rating]" class="level-rating-button <%= "selected" if @rating_vote&.rating == 1.0 %>">1</button>
<button value="2" name="rating_vote[rating]" class="level-rating-button <%= "selected" if @rating_vote&.rating == 2.0 %>">2</button>
<button value="3" name="rating_vote[rating]" class="level-rating-button <%= "selected" if @rating_vote&.rating == 3.0 %>">3</button>
<button value="4" name="rating_vote[rating]" class="level-rating-button <%= "selected" if @rating_vote&.rating == 4.0 %>">4</button>
<button value="5" name="rating_vote[rating]" class="level-rating-button <%= "selected" if @rating_vote&.rating == 5.0 %>">5</button>
<button value="6" name="rating_vote[rating]" class="level-rating-button <%= "selected" if @rating_vote&.rating == 6.0 %>">6</button>
<button value="7" name="rating_vote[rating]" class="level-rating-button <%= "selected" if @rating_vote&.rating == 7.0 %>">7</button>
<button value="8" name="rating_vote[rating]" class="level-rating-button <%= "selected" if @rating_vote&.rating == 8.0 %>">8</button>
<button value="9" name="rating_vote[rating]" class="level-rating-button <%= "selected" if @rating_vote&.rating == 9.0 %>">9</button>
<button value="10" name="rating_vote[rating]" class="level-rating-button <%= "selected" if @rating_vote&.rating == 10.0 %>">10</button>
<% rating_hash = { "Novice" => 1, "Beginner" => 2, "Mid-level" => 3, "Advanced" => 4, "Expert" => 5 } %>
<h2>Experience Level of Post</h2>
<% rating_hash.each do |rating_name, rating_level| %>
<button
value="<%= rating_level %>"
name="rating_vote_<%= rating_level %>"
id="js__rating__vote__<%= rating_level %>"
class="level-rating-button article-mod-page <%= "selected" if @rating_vote&.rating == rating_level.to_f %>"
data-user-id="<%= current_user.id %>"
data-article-id="<%= @moderatable.id %>"
data-group="experience_level"
style="margin: 0.5rem 0;">
<div class="level-rating-content">
<span class="level-rating-number"><%= rating_level %> - </span>
<span class="level-rating-text"><%= rating_name %></span>
</div>
</button>
<% end %>
<p>
Who <em>might</em> find this post most valuable, based on overall experience level?
@ -170,12 +201,25 @@
<script defer>
setTimeout(function () {
var butts = document.getElementsByClassName('reaction-button');
var butts = document.querySelectorAll('.reaction-button, .reaction-vomit-button');
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');
}
}
for (var i = 0; i < butts.length; i++) {
var butt = butts[i];
butt.onclick = function (event) {
event.preventDefault();
var thisButt = this;
applyReactedClass(thisButt.dataset.category);
thisButt.classList.add('reacted');
function successCb(response) {
@ -200,5 +244,59 @@
});
};
}
// Experience-Level JS
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 fetch('/rating_votes', {
method: 'POST',
headers: {
Accept: 'application/json',
'X-CSRF-Token': window.csrfToken,
'Content-Type': 'application/json',
},
credentials: 'same-origin',
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);
}
}
document.querySelectorAll('.level-rating-button').forEach((btn) => {
btn.addEventListener('click', () => {
updateExperienceLevel(
btn.dataset.userId,
btn.dataset.articleId,
btn.value,
btn.dataset.group,
);
});
});
}, 200)
</script>

View file

@ -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"

View file

@ -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: {

View file

@ -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.

View file

@ -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.

View file

@ -3,7 +3,7 @@
"baseUrl": "./",
"paths": {
"@crayons/*": ["app/javascript/crayons/*"],
"@utilities/*": ["app/javascript/utlities/*"]
"@utilities/*": ["app/javascript/utilities/*"]
}
},
"exclude": ["node_modules", "**/node_modules/*"]

View file

@ -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

View file

@ -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)).

View file

@ -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