Report a Message in Connect (#12229)

* Frontend Ready for Connect Report Abuse

* add feedback api

* js defination fix

* Added Hooks to the Component

* add json response in feedback

* Block popup added

* fix render issue

* Made changes in internal view

* change error message

* Added few design changes =

* add test cases

* Update app/javascript/chat/actions/requestActions.js

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

* add PR suggestions

* add backend test cases

* report abuse form close

* Update app/javascript/chat/actions/requestActions.js

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

* Update app/javascript/chat/ReportAbuse/index.jsx

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

* add test cases

* Update app/javascript/chat/ReportAbuse/index.jsx

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

* Update app/javascript/chat/ReportAbuse/index.jsx

Co-authored-by: Marcy Sutton <holla@marcysutton.com>

* Update app/javascript/chat/ReportAbuse/index.jsx

Co-authored-by: Marcy Sutton <holla@marcysutton.com>

* group the fieldset

* fix report abuse api

* fix test case

* fix request test case

* fix typo

* cleaned up markup in report abuse component.

* Fixed spacing between abuse options.

* Fixed wording in report abuse confirmation.

* Removed unnecessary data-testid and aria-label attributes.

* Added some top margin to the report abuse form.

* Update app/javascript/chat/message.jsx

Co-authored-by: Suzanne Aitchison <suzanne@forem.com>

* Added a legend to the the fieldset.

* Update app/javascript/chat/actions/requestActions.js

Co-authored-by: Michael Kohl <citizen428@dev.to>

Co-authored-by: Sarthak <7lovesharma7@gmail.com>
Co-authored-by: Narender Singh <narender2031@gmail.com>
Co-authored-by: Marcy Sutton <holla@marcysutton.com>
Co-authored-by: Suzanne Aitchison <suzanne@forem.com>
Co-authored-by: Michael Kohl <citizen428@dev.to>
This commit is contained in:
Nick Taylor 2021-01-19 12:07:47 -05:00 committed by GitHub
parent ed04875074
commit 86eb75cee7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 461 additions and 37 deletions

View file

@ -1266,6 +1266,21 @@
}
}
.report__abuse-options {
.crayons-field + .crayons-field {
margin-top: var(--su-2);
}
}
.report__abuse__button {
width: 135px;
}
.reported__message {
border: 1px solid;
max-height: 300px;
overflow: scroll;
}
.membership-section {
position: relative;

View file

@ -10,7 +10,7 @@ class FeedbackMessagesController < ApplicationController
@feedback_message = FeedbackMessage.new(params)
recaptcha_enabled = ReCaptcha::CheckEnabled.call(current_user)
if (!recaptcha_enabled || recaptcha_verified?) && @feedback_message.save
if (!recaptcha_enabled || recaptcha_verified? || connect_feedback?) && @feedback_message.save
Slack::Messengers::Feedback.call(
user: current_user,
type: feedback_message_params[:feedback_type],
@ -20,12 +20,24 @@ class FeedbackMessagesController < ApplicationController
)
rate_limiter.track_limit_by_action(:feedback_message_creation)
redirect_to feedback_messages_path
respond_to do |format|
format.html { redirect_to feedback_messages_path }
format.json { render json: { success: true, message: "Your report is submitted" } }
end
else
@previous_message = feedback_message_params[:message]
flash[:notice] = "Make sure the forms are filled 🤖"
render "pages/report_abuse"
respond_to do |format|
format.html { render "pages/report_abuse" }
format.json do
render json: {
success: false,
message: @feedback_message.errors_as_sentence,
status: :bad_request
}
end
end
end
end
@ -36,8 +48,12 @@ class FeedbackMessagesController < ApplicationController
params["g-recaptcha-response"] && verify_recaptcha(recaptcha_params)
end
def connect_feedback?
feedback_message_params[:feedback_type] == "connect"
end
def feedback_message_params
allowed_params = %i[message feedback_type category reported_url]
allowed_params = %i[message feedback_type category reported_url offender_id]
params.require(:feedback_message).permit(allowed_params)
end
end

View file

@ -0,0 +1,149 @@
import { h, Fragment } from 'preact';
import PropTypes from 'prop-types';
import { useState } from 'preact/hooks';
import { reportAbuse, blockUser } from '../actions/requestActions';
import { addSnackbarItem } from '../../Snackbar';
import { Button, FormField, RadioButton } from '@crayons';
/**
* This component render the report abuse
*
* @param {object} props
* @param {object} props.data
* @param {function} props.closeReportAbuseForm
*
* @component
*
* @example
*
* <ReportAbuse
* data={data}
* closeReportAbuseForm={closeReportAbuseForm}
* />
*
*/
function ReportAbuse({ data, closeReportAbuseForm }) {
const [category, setCategory] = useState(null);
const handleChange = (e) => {
setCategory(e.target.value);
};
const handleSubmit = async () => {
const response = await reportAbuse(
data.message,
'connect',
category,
data.user_id,
);
const { success, message } = response;
if (success) {
const confirmBlock = window.confirm(
`The message will be reported.\n\nWould you like to block this person as well?\n\nThis will:
- prevent them from commenting on your posts
- block all notifications from them
- prevent them from messaging you via chat`,
);
if (confirmBlock) {
const response = await blockUser(data.user_id);
if (response.result === 'blocked') {
addSnackbarItem({
message:
'Your report has been submitted and the user has been blocked',
});
}
} else {
addSnackbarItem({ message: 'Your report has been submitted.' });
}
closeReportAbuseForm();
} else {
addSnackbarItem({ message });
}
};
return (
<Fragment>
<section className="mt-7 p-4 grid gap-2 crayons-card mb-4">
<h1 className="lh-tight mb-4 mt-0">Report Abuse</h1>
<p>
Thank you for reporting any abuse that violates our{' '}
<a href="/code-of-conduct">code of conduct</a> or{' '}
<a href="/terms">terms and conditions</a>. We continue to try to make
this environment a great one for everybody.
</p>
<fieldset className="report__abuse-options p-4 justify-between">
<legend>Why is this content inappropriate?</legend>
<FormField variant="radio">
<RadioButton
id="rude_or_vulgar"
name="rude_or_vulgar"
value="rude or vulgar"
checked={category === 'rude or vulgar'}
onClick={handleChange}
/>
<label htmlFor="rude_or_vulgar" className="crayons-field__label">
Rude or vulgar
</label>
</FormField>
<FormField variant="radio">
<RadioButton
id="harassment"
name="harassment"
value="harassment"
checked={category === 'harassment'}
onClick={handleChange}
/>
<label htmlFor="harassment" className="crayons-field__label">
Harassment or hate speech
</label>
</FormField>
<FormField variant="radio">
<RadioButton
id="spam"
name="spam"
value="spam"
checked={category === 'spam'}
onClick={handleChange}
/>
<label htmlFor="spam" className="crayons-field__label">
Spam or copyright issue
</label>
</FormField>
<FormField variant="radio">
<RadioButton
id="listings"
name="listings"
value="listings"
checked={category === 'listings'}
onClick={handleChange}
/>
<label htmlFor="listings" className="crayons-field__label">
Inappropriate listings message/category
</label>
</FormField>
<h2>Message to Report</h2>
<div
className="reported__message p-2 mt-2 mb-3"
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: data.message }}
/>
<Button disabled={category === null} size="s" onClick={handleSubmit}>
Report Message
</Button>
</fieldset>
</section>
</Fragment>
);
}
ReportAbuse.propTypes = {
resource: PropTypes.shape({
data: PropTypes.shape({
user_id: PropTypes.number.isRequired,
message: PropTypes.element.isRequired,
}),
}).isRequired,
};
export default ReportAbuse;

View file

@ -0,0 +1,44 @@
import { h } from 'preact';
import { render } from '@testing-library/preact';
import { axe } from 'jest-axe';
import ReportAbuse from '../ReportAbuse';
describe('<ReportAbuse />', () => {
it('should have no a11y violations', async () => {
const { container } = render(
<ReportAbuse
data={{
message: 'HI',
user_id: 1,
}}
/>,
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('should render the component', () => {
const { getByText, getByLabelText } = render(
<ReportAbuse data={{ message: 'HI', user_id: 1 }} />,
);
expect(getByText('Report Abuse')).toBeDefined();
const vulgarInput = getByLabelText('Rude or vulgar');
expect(vulgarInput.value).toEqual('rude or vulgar');
const harassmentInput = getByLabelText('Harassment or hate speech');
expect(harassmentInput.value).toEqual('harassment');
const listingsInput = getByLabelText(
'Inappropriate listings message/category',
);
expect(listingsInput.value).toEqual('listings');
const spamInput = getByLabelText('Spam or copyright issue');
expect(spamInput.value).toEqual('spam');
expect(getByText('Report Message')).toBeDefined();
});
});

View file

@ -92,3 +92,52 @@ export async function updateMembership(membershipId, userAction) {
return response.json();
}
/**
*
* @param {string} feedback_message
* @param {string} type_of_feedback
* @param {string} category
* @param {string} reported_url
*/
export async function reportAbuse(
feedback_message,
feedback_type,
category,
offender_id,
) {
const response = await request('/feedback_messages', {
method: 'POST',
body: {
feedback_message: {
message: feedback_message,
feedback_type,
category,
offender_id,
},
},
});
return response.json();
}
/**
* Blocks a user with the given ID from using Connect
*
* @param {number} userId
*
*
*/
export async function blockUser(userId) {
const response = await request('/user_blocks', {
method: 'POST',
body: {
user_block: {
blocked_id: userId,
},
},
});
return response.json();
}

View file

@ -1073,6 +1073,15 @@ export default class Chat extends Component {
}));
};
closeReportAbuseForm = () => {
const { activeChannelId } = this.state;
this.setActiveContentState(activeChannelId, null);
this.setState({
fullscreenContent: null,
expanded: window.innerWidth > NARROW_WIDTH_LIMIT,
});
};
setActiveContent = (response) => {
const { activeChannelId } = this.state;
this.setActiveContentState(activeChannelId, response);
@ -1184,7 +1193,6 @@ export default class Chat extends Component {
);
}
}
return messages[activeChannelId].map((message) =>
message.action ? (
<ActionMessage
@ -1209,11 +1217,21 @@ export default class Chat extends Component {
onContentTrigger={this.triggerActiveContent}
onDeleteMessageTrigger={this.triggerDeleteMessage}
onEditMessageTrigger={this.triggerEditMessage}
onReportMessageTrigger={this.triggerReportMessage}
/>
),
);
};
triggerReportMessage = (messageId) => {
const { activeChannelId, messages } = this.state;
this.setActiveContent({
data: messages[activeChannelId].find(
(message) => message.id === messageId,
),
type_of: 'message-report-abuse',
});
};
triggerChannelFilter = (e) => {
const { channelTypeFilter } = this.state;
const filters =
@ -1594,6 +1612,7 @@ export default class Chat extends Component {
resource={state.activeContent[state.activeChannelId]}
activeChannel={state.activeChannel}
fullscreen={state.fullscreenContent === 'sidecar'}
closeReportAbuseForm={this.closeReportAbuseForm}
/>
<VideoContent
videoPath={state.videoPath}

View file

@ -5,6 +5,7 @@ import ChannelRequest from './channelRequest';
import RequestManager from './RequestManager/RequestManager';
import ChatChannelSettings from './ChatChannelSettings/ChatChannelSettings';
import Draw from './draw';
import ReportAbuse from './ReportAbuse';
const smartSvgIcon = (content, d) => (
<svg
@ -33,13 +34,20 @@ export default class Content extends Component {
fullscreen: PropTypes.bool.isRequired,
onTriggerContent: PropTypes.func.isRequired,
updateRequestCount: PropTypes.func.isRequired,
closeReportAbuseForm: PropTypes.func.isRequired,
};
render() {
const { onTriggerContent, fullscreen, resource } = this.props;
const {
onTriggerContent,
fullscreen,
resource,
closeReportAbuseForm,
} = this.props;
if (!resource) {
return '';
}
return (
// TODO: A button (role="button") cannot contain other interactive elements, i.e. buttons.
// TODO: These should have key click events as well.
@ -80,13 +88,16 @@ export default class Content extends Component {
'M20 3h2v6h-2V5h-4V3h4zM4 3h4v2H4v4H2V3h2zm16 16v-4h2v6h-6v-2h4zM4 19h4v2H2v-6h2v4z',
)}
</button>
<Display resource={resource} />
<Display
resource={resource}
closeReportAbuseForm={closeReportAbuseForm}
/>
</div>
);
}
}
const Display = ({ resource }) => {
function Display({ resource, closeReportAbuseForm }) {
switch (resource.type_of) {
case 'loading-user':
return <div className="loading-user" title="Loading user" />;
@ -116,7 +127,14 @@ const Display = ({ resource }) => {
handleLeavingChannel={resource.handleLeavingChannel}
/>
);
case 'message-report-abuse':
return (
<ReportAbuse
data={resource.data}
closeReportAbuseForm={closeReportAbuseForm}
/>
);
default:
return null;
}
};
}

View file

@ -19,6 +19,7 @@ const Message = ({
profileImageUrl,
onContentTrigger,
onDeleteMessageTrigger,
onReportMessageTrigger,
onEditMessageTrigger,
}) => {
const spanStyle = { color };
@ -60,6 +61,22 @@ const Message = ({
</div>
</div>
);
const dropdownReport = (
<div className="message__actions">
<span className="ellipsis__menubutton">
<img src={ThreeDotsIcon} alt="message actions" />
</span>
<div className="messagebody__dropdownmenu report__abuse__button">
<Button
variant="ghost-danger"
onClick={(_) => onReportMessageTrigger(id)}
>
Report Abuse
</Button>
</div>
</div>
);
return (
<div className="chatmessage">
@ -119,7 +136,7 @@ const Message = ({
' '
)}
</div>
{userID === currentUserId ? dropdown : ' '}
{userID === currentUserId ? dropdown : dropdownReport}
</div>
<div className="chatmessage__bodytext">
<MessageArea />
@ -143,6 +160,7 @@ Message.propTypes = {
onContentTrigger: PropTypes.func.isRequired,
onDeleteMessageTrigger: PropTypes.func.isRequired,
onEditMessageTrigger: PropTypes.func.isRequired,
onReportMessageTrigger: PropTypes.func.isRequired,
};
Message.defaultProps = {

View file

@ -128,7 +128,11 @@ class FollowUsers extends Component {
}
return (
<button type="button" class="crayons-btn crayons-btn--ghost-brand -ml-2" onClick={() => this.handleSelectAll()}>
<button
type="button"
class="crayons-btn crayons-btn--ghost-brand -ml-2"
onClick={() => this.handleSelectAll()}
>
{followText}
</button>
);

View file

@ -17,7 +17,7 @@ const menus = [...document.getElementsByClassName('js-hamburger-trigger')];
const moreMenus = [...document.getElementsByClassName('js-nav-more-trigger')];
getInstantClick().then((spa) => {
spa.on('change', function () {
spa.on('change', () => {
const { currentPage } = document.getElementById('page-content').dataset;
setCurrentPageIconLink(currentPage, getPageEntries());

View file

@ -53,9 +53,7 @@ function renderTagsFollowed(user = userData()) {
}
function renderSidebar() {
const sidebarContainer = document.getElementById(
'sidebar-wrapper-right',
);
const sidebarContainer = document.getElementById('sidebar-wrapper-right');
// If the screen's width is less than 1024px we don't need this extra data.
if (sidebarContainer && screen.width > 1023 && window.location.pathname === '/') {
@ -67,11 +65,10 @@ function renderSidebar() {
}
}
const feedTimeFrame = frontPageFeedPathNames.get(window.location.pathname);
if (!document.getElementById('featured-story-marker')) {
const waitingForDataLoad = setInterval(function dataLoadedCheck() {
const waitingForDataLoad = setInterval(() => {
const { user = null, userStatus } = document.body.dataset;
if (userStatus === 'logged-out') {
return;

View file

@ -19,7 +19,11 @@
<div class="row">
<div class="col-9">
<h5 class="fw-bold">
<% if feedback_message.feedback_type == "connect" %>
Reporter and Affected:
<% else %>
Reporter:
<% end %>
</h5>
<h5>
<% if feedback_message.reporter_id? %>
@ -29,19 +33,35 @@
Anonymous
<% end %>
</h5>
<% if feedback_message.feedback_type == "connect" %>
<h5 class="fw-bold">
Offender:
</h5>
<h5>
<%= feedback_message.offender.name %>
<a href="<%= feedback_message.offender.path %>">@<%= feedback_message.offender.username %></a>
</h5>
<% else %>
<h5 class="fw-bold">
Reported URL (new tab):
</h5>
<h5>
<a href="<%= feedback_message.reported_url %>" target="_blank" rel="noopener"><%= feedback_message.reported_url %></a>
</h5>
Reported URL (new tab):
</h5>
<h5>
<a href="<%= feedback_message.reported_url %>" target="_blank" rel="noopener"><%= feedback_message.reported_url %></a>
</h5>
<% end %>
</div>
<div class="col-3">
<h3>
<h3 class="report__tags">
<span class="badge badge-warning float-right"><%= feedback_message.category.titleize %></span>
<% if feedback_message.feedback_type == "connect" %>
<span class="badge badge-warning float-right badge-connect"><%= feedback_message.feedback_type %></span>
<% end %>
</h3>
</div>
<div class="col-12">
<% if feedback_message.feedback_type != "connect" %>
<h5 class="fw-bold">
Message:
</h5>
@ -52,6 +72,14 @@
<%= feedback_message.message %>
<% end %>
</p>
<% else %>
<h5 class="fw-bold">
Message from Offender:
</h5>
<div class="reported__message">
<%= raw(feedback_message.message) %>
</div>
<% end %>
</div>
</div>
<hr>

View file

@ -22,4 +22,21 @@
color: black;
text-align: center;
}
.badge-connect {
background-color: #26d9ca;
margin-top: 5px;
text-transform: capitalize;
}
.reported__message {
border: 1px solid;
margin: 20px auto;
padding: 20px;
max-height: 300px;
overflow: scroll;
}
.report__tags{
display: grid;
justify-items: end;
}
</style>

View file

@ -15,7 +15,7 @@
<a href="<%= readinglist_path %>" class="crayons-link crayons-link--block">Reading list</a>
<a href="<%= user_settings_path %>" class="crayons-link crayons-link--block">Settings</a>
</div>
<div class="p-1">
<a href="<%= signout_confirm_path %>" class="crayons-link crayons-link--block" id="last-nav-link">Sign Out</a>
</div>

View file

@ -4,19 +4,22 @@ We use the [Field Test](https://github.com/ankane/field_test) gem for conducting
simple A/B tests.
If you want to propose an A/B test of a feature, you may make a pull request
which defines the hypotheses and what admins should look for to declare a winner.
As A/B tests are going to have results that may differ from Forem to Forem, the
process is relatively immature. In the future we may have more studies that can return anonymous ecosystem-wide results.
which defines the hypotheses and what admins should look for to declare a
winner. As A/B tests are going to have results that may differ from Forem to
Forem, the process is relatively immature. In the future we may have more
studies that can return anonymous ecosystem-wide results.
A/B tests are inherently the most useful in _large_ Forems, where qualitative
feedback may be more useful on small Forems. As such, [DEV](https://dev.to)
is our largest Forem and therefore can provide us with the most feedback for
our existing A/B tests. However, we must keep in mind that DEV results may
not apply well to future large Forems. We should seek to re-run useful experiments within the ecosystem after time has passed.
feedback may be more useful on small Forems. As such, [DEV](https://dev.to) is
our largest Forem and therefore can provide us with the most feedback for our
existing A/B tests. However, we must keep in mind that DEV results may not apply
well to future large Forems. We should seek to re-run useful experiments within
the ecosystem after time has passed.
## Creating a new A/B test
Follow the guidelines of the field test gem and add the test info to [config/field_test.yml](https://github.com/forem/forem/blob/master/config/field_test.yml).
Follow the guidelines of the field test gem and add the test info to
[config/field_test.yml](https://github.com/forem/forem/blob/master/config/field_test.yml).
Then where you want to trigger the variant, you'll add some code like this:
@ -38,9 +41,9 @@ Then where you want to trigger the variant, you'll add some code like this:
end
```
Which would find or create the test variant for that user in particular. If
this code is not called in the controller or view, you'll need to first
include the gem helpers at the top of the file...
Which would find or create the test variant for that user in particular. If this
code is not called in the controller or view, you'll need to first include the
gem helpers at the top of the file...
```
include FieldTest::Helpers
@ -53,4 +56,5 @@ To record a successful field test outcome, you should call something like this
.perform_async(user_id, :follow_implicit_points, "user_creates_reaction")
```
And modify that class as needed to determine whether to record the successful trial.
And modify that class as needed to determine whether to record the successful
trial.

View file

@ -1,6 +1,8 @@
require "rails_helper"
RSpec.describe "feedback_messages", type: :request do
let(:user) { create(:user) }
describe "POST /feedback_messages" do
def mock_recaptcha_verification
# rubocop:disable RSpec/AnyInstance
@ -49,6 +51,26 @@ RSpec.describe "feedback_messages", type: :request do
end
end
context "when feedback is created by chat" do
before do
sign_in user
post "/feedback_messages", params: {
feedback_message: {
message: "Test Message",
feedback_type: "connect",
category: "rude or vulgar",
offender_id: user.id
}
}, as: :json
end
it "creates a feedback message" do
expect(response.status).to eq(200)
expect(response.parsed_body["success"]).to eq(true)
expect(FeedbackMessage.where(offender_id: user.id).count).to eq(1)
end
end
context "with valid params and recaptcha not configured" do
before do
allow(SiteConfig).to receive(:recaptcha_secret_key).and_return(nil)

View file

@ -0,0 +1,24 @@
require "rails_helper"
RSpec.describe "Feedback report by chat channel messages", type: :system do
let(:user) { create(:user) }
context "when user create a report abuse feedback message" do
before do
sign_in user
end
it "feedback messahe should increase by one", js: true do
expect do
post "/feedback_messages", params: {
feedback_message: {
message: "Test Message",
feedback_type: "connect",
category: "rude or vulgar",
offender_id: user.id
}
}, as: :json
end.to change(FeedbackMessage, :count).by(1)
end
end
end