Add quote reporting feature (#2372) by Bruception

* Add initial quote reporting logic

* Remove report status

* Add initial frontend

* Add submit logic

* Add report quote button to rating popup

* Refactor quoteId argument

* Remove console log

* Add captcha to request validation schema

* Use captcha id for value and reset

* Update report data schema

* Hide report popup on complete

* quote search styling update

* updated report quote styling

* tofixed

* tofixed

* moved report button to the result screen
styling changes

* resetting captcha after hiding to avoid ugly animation

* select2 styling update

* lowercase

Co-authored-by: Miodec <bartnikjack@gmail.com>
This commit is contained in:
Bruce Berrios 2022-01-30 18:53:56 -05:00 committed by GitHub
parent 776b0290ec
commit 974e50ec48
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 435 additions and 48 deletions

View file

@ -0,0 +1,40 @@
const { v4: uuidv4 } = require("uuid");
const ReportDAO = require("../../dao/report");
const UserDAO = require("../../dao/user");
const MonkeyError = require("../../handlers/error");
const Captcha = require("../../handlers/captcha");
class QuotesController {
static async reportQuote(req, res) {
const { uid } = req.decodedToken;
const user = await UserDAO.getUser(uid);
if (user.cannotReport) {
throw new MonkeyError(403, "You don't have permission to do this.");
}
const { quoteId, quoteLanguage, reason, comment, captcha } = req.body;
if (!(await Captcha.verify(captcha))) {
throw new MonkeyError(400, "Captcha check failed.");
}
const newReport = {
id: uuidv4(),
type: "quote",
timestamp: new Date().getTime(),
uid,
details: {
contentId: `${quoteLanguage}-${quoteId}`,
reason,
comment,
},
};
await ReportDAO.createReport(newReport);
res.sendStatus(200);
}
}
module.exports = QuotesController;

View file

@ -3,8 +3,12 @@ const { authenticateRequest } = require("../../middlewares/auth");
const { Router } = require("express");
const NewQuotesController = require("../controllers/new-quotes");
const QuoteRatingsController = require("../controllers/quote-ratings");
const QuotesController = require("../controllers/quotes");
const RateLimit = require("../../middlewares/rate-limit");
const { requestValidation } = require("../../middlewares/apiUtils");
const {
asyncHandlerWrapper,
requestValidation,
} = require("../../middlewares/apiUtils");
const SUPPORTED_QUOTE_LANGUAGES = require("../../constants/quoteLanguages");
const quotesRouter = Router();
@ -71,11 +75,10 @@ quotesRouter.post(
)
.required(),
comment: joi.string().allow("").max(250).required(),
captcha: joi.string().required(),
},
}),
(req, res) => {
res.sendStatus(200);
}
asyncHandlerWrapper(QuotesController.reportQuote)
);
module.exports = quotesRouter;

29
backend/dao/report.js Normal file
View file

@ -0,0 +1,29 @@
const MonkeyError = require("../handlers/error");
const { mongoDB } = require("../init/mongodb");
const MAX_REPORTS = 100;
class ReportDAO {
static async createReport(report) {
const reports = await mongoDB().collection("reports").find().toArray();
if (reports.length >= MAX_REPORTS) {
throw new MonkeyError(
503,
"Reports are not being accepted at this time. Please try again later."
);
}
const reportAlreadyExists = reports.find((existingReport) => {
return existingReport.details.contentId === report.details.contentId;
});
if (reportAlreadyExists) {
throw new MonkeyError(409, "A report for this content already exists.");
}
await mongoDB().collection("reports").insertOne(report);
}
}
module.exports = ReportDAO;

View file

@ -1,11 +1,33 @@
const joi = require("joi");
const MonkeyError = require("../handlers/error");
/**
* This utility serves as an alternative to wrapping express handlers with try/catch statements.
* Any routes that use an async handler function should wrap the handler with this function.
* Without this, any errors thrown will not be caught by the error handling middleware, and
* the app will hang!
*/
function asyncHandlerWrapper(handler) {
return async (req, res, next) => {
try {
const handlerData = await handler(req, res);
if (!res.headersSent && handlerData) {
res.json(handlerData);
}
next();
} catch (error) {
next(error);
}
};
}
function requestValidation(validationSchema) {
return (req, res, next) => {
// In dev environments, as an alternative to token authentication,
// you can pass the authentication middleware by having a user id in the body.
// Inject the user id into the schema so that validation will not fail.
/**
* In dev environments, as an alternative to token authentication,
* you can pass the authentication middleware by having a user id in the body.
* Inject the user id into the schema so that validation will not fail.
*/
if (process.env.MODE === "dev") {
validationSchema.body = {
uid: joi.any(),
@ -28,5 +50,6 @@ function requestValidation(validationSchema) {
}
module.exports = {
asyncHandlerWrapper,
requestValidation,
};

View file

@ -142,6 +142,7 @@ const refactoredSrc = [
"./src/js/popups/quote-search-popup.js",
"./src/js/popups/quote-submit-popup.js",
"./src/js/popups/quote-approve-popup.js",
"./src/js/popups/quote-report-popup.js",
"./src/js/popups/rate-quote-popup.js",
"./src/js/popups/version-popup.js",
"./src/js/popups/support-popup.js",

View file

@ -666,6 +666,7 @@ $(document).keydown((event) => {
!$("#quoteSearchPopupWrapper").hasClass("hidden") ||
!$("#quoteSubmitPopupWrapper").hasClass("hidden") ||
!$("#quoteApprovePopupWrapper").hasClass("hidden") ||
!$("#quoteReportPopupWrapper").hasClass("hidden") ||
!$("#wordFilterPopupWrapper").hasClass("hidden");
const allowTyping =

View file

@ -0,0 +1,138 @@
import * as Misc from "./misc";
import * as Notifications from "./notifications";
import axiosInstance from "./axios-instance";
import Config from "./config";
import * as Loader from "./loader";
const CAPTCHA_ID = 1;
const state = {
previousPopupShowCallback: undefined,
quoteToReport: undefined,
};
const defaultOptions = {
quoteId: -1,
previousPopupShowCallback: () => {},
noAnim: false,
};
export async function show(options = defaultOptions) {
if ($("#quoteReportPopupWrapper").hasClass("hidden")) {
const { quoteId, previousPopupShowCallback, noAnim } = options;
state.previousPopupShowCallback = previousPopupShowCallback;
const { quotes } = await Misc.getQuotes(Config.language);
state.quoteToReport = quotes.find((quote) => {
return quote.id === quoteId;
});
$("#quoteReportPopup .quote").text(state.quoteToReport.text);
$("#quoteReportPopup .reason").val("Grammatical error");
$("#quoteReportPopup .comment").val("");
$("#quoteReportPopup .characterCount").text("-");
$("#quoteReportPopup .reason").select2({
minimumResultsForSearch: Infinity,
});
$("#quoteReportPopupWrapper")
.stop(true, true)
.css("opacity", 0)
.removeClass("hidden")
.animate({ opacity: 1 }, noAnim ? 0 : 100, (e) => {
$("#quoteReportPopup textarea").focus().select();
});
}
}
export async function hide() {
if (!$("#quoteReportPopupWrapper").hasClass("hidden")) {
$("#quoteReportPopupWrapper")
.stop(true, true)
.css("opacity", 1)
.animate(
{
opacity: 0,
},
100,
(e) => {
grecaptcha.reset(CAPTCHA_ID);
$("#quoteReportPopupWrapper").addClass("hidden");
if (state.previousPopupShowCallback) {
state.previousPopupShowCallback();
}
}
);
}
}
async function submitReport() {
const captchaResponse = grecaptcha.getResponse(CAPTCHA_ID);
if (!captchaResponse) {
Notifications.add("Please complete the captcha.");
return;
}
const requestBody = {
quoteId: state.quoteToReport.id.toString(),
quoteLanguage: Config.language,
reason: $("#quoteReportPopup .reason").val(),
comment: $("#quoteReportPopup .comment").val(),
captcha: captchaResponse,
};
if (!requestBody.reason) {
Notifications.add("Please select a valid report reason.");
return;
}
const characterDifference = requestBody.comment.length - 250;
if (characterDifference > 0) {
Notifications.add(
`Report comment is ${characterDifference} character(s) too long.`
);
return;
}
Loader.show();
let response;
try {
response = await axiosInstance.post("/quotes/report", requestBody);
} catch (e) {
Loader.hide();
let msg = e?.response?.data?.message ?? e.message;
Notifications.add("Failed to report quote: " + msg, -1);
return;
}
Loader.hide();
if (response.status !== 200) {
Notifications.add(response.data.message);
} else {
Notifications.add("Report submitted. Thank you!", 1);
hide();
}
}
$("#quoteReportPopupWrapper").on("mousedown", (e) => {
if ($(e.target).attr("id") === "quoteReportPopupWrapper") {
hide();
}
});
$("#quoteReportPopup .comment").on("input", (e) => {
setTimeout(() => {
const len = $("#quoteReportPopup .comment").val().length;
$("#quoteReportPopup .characterCount").text(len);
if (len > 250) {
$("#quoteReportPopup .characterCount").addClass("red");
} else {
$("#quoteReportPopup .characterCount").removeClass("red");
}
}, 1);
});
$("#quoteReportPopup .submit").on("click", async (e) => {
await submitReport();
});

View file

@ -5,6 +5,7 @@ import * as ManualRestart from "./manual-restart-tracker";
import * as TestLogic from "./test-logic";
import * as QuoteSubmitPopup from "./quote-submit-popup";
import * as QuoteApprovePopup from "./quote-approve-popup";
import * as QuoteReportPopup from "./quote-report-popup";
import * as DB from "./db";
import * as TestUI from "./test-ui";
@ -40,6 +41,8 @@ async function updateResults(searchText) {
let resultsList = $("#quoteSearchResults");
let resultListLength = 0;
const isNotAuthed = !firebase.auth().currentUser;
found.forEach(async (quote) => {
let lengthDesc;
if (quote.length < 101) {
@ -55,10 +58,16 @@ async function updateResults(searchText) {
resultsList.append(`
<div class="searchResult" id="${quote.id}">
<div class="text">${quote.text}</div>
<div class="id"><div class="sub">id</div>${quote.id}</div>
<div class="id"><div class="sub">id</div><span class="quote-id">${
quote.id
}</span></div>
<div class="length"><div class="sub">length</div>${lengthDesc}</div>
<div class="source"><div class="sub">source</div>${quote.source}</div>
<div class="resultChevron"><i class="fas fa-chevron-right"></i></div>
<div class="icon-button report ${
isNotAuthed && "hidden"
}" aria-label="Report quote" data-balloon-pos="left">
<i class="fas fa-flag"></i>
</div>
</div>
`);
}
@ -73,9 +82,13 @@ async function updateResults(searchText) {
}
}
export async function show() {
export async function show(clearText = true) {
if ($("#quoteSearchPopupWrapper").hasClass("hidden")) {
$("#quoteSearchPopup input").val("");
if (clearText) {
$("#quoteSearchPopup input").val("");
}
const quoteSearchInputValue = $("#quoteSearchPopup input").val();
if (!firebase.auth().currentUser) {
$("#quoteSearchPopup #gotoSubmitQuoteButton").addClass("hidden");
@ -94,13 +107,15 @@ export async function show() {
.css("opacity", 0)
.removeClass("hidden")
.animate({ opacity: 1 }, 100, (e) => {
$("#quoteSearchPopup input").focus().select();
updateResults("");
if (clearText) {
$("#quoteSearchPopup input").focus().select();
}
updateResults(quoteSearchInputValue);
});
}
}
export function hide(noAnim = false) {
export function hide(noAnim = false, focusWords = true) {
if (!$("#quoteSearchPopupWrapper").hasClass("hidden")) {
$("#quoteSearchPopupWrapper")
.stop(true, true)
@ -112,7 +127,9 @@ export function hide(noAnim = false) {
noAnim ? 0 : 100,
(e) => {
$("#quoteSearchPopupWrapper").addClass("hidden");
TestUI.focusWords();
if (focusWords) {
TestUI.focusWords();
}
}
);
}
@ -155,6 +172,9 @@ $(document).on(
"click",
"#quoteSearchPopup #quoteSearchResults .searchResult",
(e) => {
if (e.target.classList.contains("report")) {
return;
}
selectedId = parseInt($(e.currentTarget).attr("id"));
apply(selectedId);
}
@ -170,6 +190,20 @@ $(document).on("click", "#quoteSearchPopup #goToApproveQuotes", (e) => {
QuoteApprovePopup.show(true);
});
$(document).on("click", "#quoteSearchPopup .report", async (e) => {
const quoteId = e.target.closest(".searchResult").id;
const quoteIdSelectedForReport = parseInt(quoteId);
hide(true, false);
QuoteReportPopup.show({
quoteId: quoteIdSelectedForReport,
noAnim: true,
previousPopupShowCallback: () => {
show(false);
},
});
});
// $("#quoteSearchPopup input").keypress((e) => {
// if (e.keyCode == 13) {
// if (!isNaN(document.getElementById("searchBox").value)) {

View file

@ -1,6 +1,7 @@
import * as DB from "./db";
import * as Loader from "./loader";
import * as Notifications from "./notifications";
import * as QuoteReportPopup from "./quote-report-popup";
import axiosInstance from "./axios-instance";
let rating = 0;
@ -80,9 +81,11 @@ function updateData() {
updateRatingStats();
}
export function show(quote) {
export function show(quote, shouldReset = true) {
if ($("#rateQuotePopupWrapper").hasClass("hidden")) {
reset();
if (shouldReset) {
reset();
}
currentQuote = quote;
rating = 0;
@ -92,6 +95,7 @@ export function show(quote) {
if (alreadyRated) {
rating = alreadyRated;
}
refreshStars();
updateData();
$("#rateQuotePopupWrapper")
@ -181,7 +185,9 @@ async function submit() {
quoteStats.average = (
Math.round((quoteStats.totalRating / quoteStats.ratings) * 10) / 10
).toFixed(1);
$(".pageTest #result #rateQuoteButton .rating").text(quoteStats.average);
$(".pageTest #result #rateQuoteButton .rating").text(
quoteStats.average.toFixed(1)
);
$(".pageTest #result #rateQuoteButton .icon").removeClass("far");
$(".pageTest #result #rateQuoteButton .icon").addClass("fas");
}
@ -211,3 +217,14 @@ $("#rateQuotePopup .stars .star").mouseout((e) => {
$("#rateQuotePopup .submitButton").click((e) => {
submit();
});
$("#rateQuotePopup #reportQuoteButton").click((e) => {
hide();
QuoteReportPopup.show({
quoteId: parseInt(currentQuote.id),
noAnim: true,
previousPopupShowCallback: () => {
show(currentQuote, false);
},
});
});

View file

@ -558,7 +558,7 @@ export function updateRateQuote(randomQuote) {
}
RateQuotePopup.getQuoteStats(randomQuote).then((quoteStats) => {
$(".pageTest #result #rateQuoteButton .rating").text(
quoteStats.average ?? ""
quoteStats.average.toFixed(1) ?? ""
);
$(".pageTest #result #rateQuoteButton")
.css({ opacity: 0 })

View file

@ -1596,6 +1596,7 @@ export async function finish(difficultyFailed = false) {
if (firebase.auth().currentUser == null) {
$(".pageTest #result #rateQuoteButton").addClass("hidden");
$(".pageTest #result #reportQuoteButton").addClass("hidden");
try {
firebase.analytics().logEvent("testCompletedNoLogin", completedEvent);
} catch (e) {
@ -1603,6 +1604,8 @@ export async function finish(difficultyFailed = false) {
}
notSignedInLastResult = completedEvent;
dontSave = true;
} else {
$(".pageTest #result #reportQuoteButton").removeClass("hidden");
}
Result.update(

View file

@ -21,6 +21,7 @@ import * as ChallengeController from "./challenge-controller";
import * as RateQuotePopup from "./rate-quote-popup";
import * as UI from "./ui";
import * as TestTimer from "./test-timer";
import * as ReportQuotePopup from "./quote-report-popup";
export let currentWordElementIndex = 0;
export let resultVisible = false;
@ -997,6 +998,13 @@ $(".pageTest #rateQuoteButton").click(async (event) => {
RateQuotePopup.show(TestLogic.randomQuote);
});
$(".pageTest #reportQuoteButton").click(async (event) => {
ReportQuotePopup.show({
quoteId: parseInt(TestLogic.randomQuote.id),
noAnim: false,
});
});
$(".pageTest #toggleBurstHeatmap").click(async (event) => {
UpdateConfig.setBurstHeatmap(!Config.burstHeatmap);
});

View file

@ -89,13 +89,13 @@ input[type="number"] {
.select2-container--default
.select2-results__option--highlighted.select2-results__option--selectable {
background-color: var(--sub-color);
color: var(--text-color);
background-color: var(--text-color);
color: var(--bg-color);
}
.select2-container--default .select2-results__option--selected {
background-color: var(--main-color);
color: var(--bg-color);
background-color: var(--bg-color);
color: var(--sub-color);
}
.select2-container--open .select2-dropdown--below {
@ -144,14 +144,14 @@ input[type="number"] {
.select2-selection--single
.select2-selection__arrow
b {
border-color: var(--main-color) transparent transparent transparent;
border-color: var(--sub-color) transparent transparent transparent;
}
.select2-container--default.select2-container--open
.select2-selection--single
.select2-selection__arrow
b {
border-color: var(--main-color) transparent;
border-color: var(--sub-color) transparent;
}
.select2-container--default .select2-search--dropdown .select2-search__field {

View file

@ -400,6 +400,9 @@
.searchResult {
display: grid;
grid-template-columns: 1fr 1fr 3fr 0fr;
grid-template-areas:
"text text text text"
"id len source report";
grid-auto-rows: auto;
width: 100%;
gap: 0.5rem;
@ -411,48 +414,41 @@
height: min-content;
.text {
grid-column-start: 1;
grid-column-end: 4;
grid-row-start: 1;
grid-row-end: 2;
grid-area: text;
overflow: visible;
color: var(--text-color);
}
.id {
grid-column-start: 1;
grid-column-end: 2;
grid-row-start: 2;
grid-row-end: 3;
grid-area: id;
font-size: 0.8rem;
color: var(--sub-color);
}
.length {
grid-column-start: 2;
grid-column-end: 3;
grid-row-start: 2;
grid-row-end: 3;
grid-area: len;
font-size: 0.8rem;
color: var(--sub-color);
}
.source {
grid-column-start: 3;
grid-column-end: 4;
grid-row-start: 2;
grid-row-end: 3;
grid-area: source;
font-size: 0.8rem;
color: var(--sub-color);
}
.resultChevron {
grid-column-start: 4;
grid-column-end: 5;
grid-row-start: 1;
grid-row-end: 3;
grid-area: chevron;
display: flex;
align-items: center;
justify-items: center;
color: var(--sub-color);
font-size: 2rem;
}
.report {
grid-area: report;
color: var(--sub-color);
transition: 0.25s;
&:hover {
color: var(--text-color);
}
}
.sub {
opacity: 0.5;
}
@ -600,6 +596,58 @@
}
}
#quoteReportPopupWrapper {
#quoteReportPopup {
background: var(--bg-color);
border-radius: var(--roundness);
padding: 2rem;
display: grid;
gap: 1rem;
width: 1000px;
grid-template-rows: auto auto auto auto auto auto auto auto auto;
height: auto;
max-height: 40rem;
overflow-y: scroll;
label {
color: var(--sub-color);
margin-bottom: -1rem;
}
.text {
color: var(--sub-color);
}
.quote {
font-size: 1.5rem;
}
.title {
font-size: 1.5rem;
color: var(--sub-color);
}
textarea {
resize: vertical;
width: 100%;
padding: 10px;
line-height: 1.2rem;
min-height: 5rem;
}
.characterCount {
position: absolute;
top: -1.25rem;
right: 0.25rem;
color: var(--sub-color);
user-select: none;
&.red {
color: var(--error-color);
}
}
}
}
#resultEditTagsPanelWrapper {
#resultEditTagsPanel {
background: var(--bg-color);

View file

@ -677,8 +677,15 @@
}
}
&.source #rateQuoteButton {
padding: 0 0.5rem;
&.source {
#rateQuoteButton,
#reportQuoteButton {
padding: 0 0.25rem;
}
#rateQuoteButton {
display: inline-grid;
gap: 0.25rem;
}
}
}

View file

@ -551,6 +551,33 @@
<div id="submitQuoteButton" class="button">Submit</div>
</div>
</div>
<div id="quoteReportPopupWrapper" class="popupWrapper hidden">
<div id="quoteReportPopup" mode="">
<div class="title">Report a Quote</div>
<div class="text">
Please report quotes responsibly. Misuse may result in you losing
access to this feature.
</div>
<label>quote</label>
<div class="quote"></div>
<label>reason</label>
<select name="report-reason" class="reason">
<option value="Grammatical error">Grammatical error</option>
<option value="Inappropriate content">Inappropriate content</option>
<option value="Low quality content">Low quality content</option>
</select>
<label>comment</label>
<div style="position: relative">
<textarea class="comment" type="text" autocomplete="off"></textarea>
<div class="characterCount">-</div>
</div>
<div
class="g-recaptcha"
data-sitekey="6Lc-V8McAAAAAJ7s6LGNe7MBZnRiwbsbiWts87aj"
></div>
<div class="button submit">Report</div>
</div>
</div>
<div id="quoteApprovePopupWrapper" class="popupWrapper hidden">
<div id="quoteApprovePopup" mode="">
<div class="top">
@ -1446,12 +1473,20 @@
<div class="group source hidden">
<div class="top">
source
<span
id="reportQuoteButton"
class="icon-button hidden"
aria-label="Report quote"
data-balloon-pos="up"
style="display: inline-block"
>
<i class="icon fas fa-flag"></i>
</span>
<span
id="rateQuoteButton"
class="icon-button hidden"
aria-label="Rate quote"
data-balloon-pos="up"
style="display: inline-block"
>
<i class="icon far fa-star"></i>
<span class="rating"></span>