mirror of
https://github.com/wassname/talk.git
synced 2026-09-09 11:38:08 +08:00
[CORL-8] prevent repeat comments (#2632)
* prevent users from reposting the same comment * fix tests * fix lints * update comments for users service * make updates based on review
This commit is contained in:
committed by
Wyatt Johnson
parent
1f7fdb0d00
commit
9df473178b
@@ -28,6 +28,7 @@ it("renders all markers", () => {
|
||||
COMMENT_DETECTED_SUSPECT_WORD: 1,
|
||||
COMMENT_REPORTED_OFFENSIVE: 2,
|
||||
COMMENT_REPORTED_SPAM: 3,
|
||||
COMMENT_DETECTED_REPEAT_POST: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -71,6 +72,7 @@ it("renders some markers", () => {
|
||||
COMMENT_DETECTED_SUSPECT_WORD: 0,
|
||||
COMMENT_REPORTED_OFFENSIVE: 2,
|
||||
COMMENT_REPORTED_SPAM: 0,
|
||||
COMMENT_DETECTED_REPEAT_POST: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -32,7 +32,7 @@ const markers: Array<
|
||||
c =>
|
||||
(c.status === "PREMOD" && (
|
||||
<Localized id="moderate-marker-preMod" key={keyCounter++}>
|
||||
<Marker color="primary">Pre-Mod</Marker>
|
||||
<Marker color="primary">Pre-mod</Marker>
|
||||
</Localized>
|
||||
)) ||
|
||||
null,
|
||||
@@ -48,7 +48,7 @@ const markers: Array<
|
||||
(c.revision &&
|
||||
c.revision.actionCounts.flag.reasons.COMMENT_DETECTED_BANNED_WORD && (
|
||||
<Localized id="moderate-marker-bannedWord" key={keyCounter++}>
|
||||
<Marker color="error">Banned Word</Marker>
|
||||
<Marker color="error">Banned word</Marker>
|
||||
</Localized>
|
||||
)) ||
|
||||
null,
|
||||
@@ -57,7 +57,7 @@ const markers: Array<
|
||||
c.revision.actionCounts.flag.reasons.COMMENT_DETECTED_SUSPECT_WORD && (
|
||||
<Localized id="moderate-marker-suspectWord" key={keyCounter++}>
|
||||
<Marker color="error" variant="filled">
|
||||
Suspect Word
|
||||
Suspect word
|
||||
</Marker>
|
||||
</Localized>
|
||||
)) ||
|
||||
@@ -66,7 +66,7 @@ const markers: Array<
|
||||
(c.revision &&
|
||||
c.revision.actionCounts.flag.reasons.COMMENT_DETECTED_SPAM && (
|
||||
<Localized id="moderate-marker-spamDetected" key={keyCounter++}>
|
||||
<Marker color="error">Spam Detected</Marker>
|
||||
<Marker color="error">Spam detected</Marker>
|
||||
</Localized>
|
||||
)) ||
|
||||
null,
|
||||
@@ -78,11 +78,19 @@ const markers: Array<
|
||||
</Localized>
|
||||
)) ||
|
||||
null,
|
||||
c =>
|
||||
(c.revision &&
|
||||
c.revision.actionCounts.flag.reasons.COMMENT_DETECTED_REPEAT_POST && (
|
||||
<Localized id="moderate-marker-repeatPost" key={keyCounter++}>
|
||||
<Marker color="error">Repeat comment</Marker>
|
||||
</Localized>
|
||||
)) ||
|
||||
null,
|
||||
c =>
|
||||
(c.revision &&
|
||||
c.revision.actionCounts.flag.reasons.COMMENT_DETECTED_RECENT_HISTORY && (
|
||||
<Localized id="moderate-marker-recentHistory" key={keyCounter++}>
|
||||
<Marker color="error">Recent History</Marker>
|
||||
<Marker color="error">Recent history</Marker>
|
||||
</Localized>
|
||||
)) ||
|
||||
null,
|
||||
@@ -162,6 +170,7 @@ const enhanced = withFragmentContainer<MarkersContainerProps>({
|
||||
COMMENT_DETECTED_SUSPECT_WORD
|
||||
COMMENT_REPORTED_OFFENSIVE
|
||||
COMMENT_REPORTED_SPAM
|
||||
COMMENT_DETECTED_REPEAT_POST
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+19
-8
@@ -16,6 +16,7 @@ exports[`renders all markers 1`] = `
|
||||
"COMMENT_DETECTED_BANNED_WORD": 1,
|
||||
"COMMENT_DETECTED_LINKS": 1,
|
||||
"COMMENT_DETECTED_RECENT_HISTORY": 1,
|
||||
"COMMENT_DETECTED_REPEAT_POST": 1,
|
||||
"COMMENT_DETECTED_SPAM": 1,
|
||||
"COMMENT_DETECTED_SUSPECT_WORD": 1,
|
||||
"COMMENT_DETECTED_TOXIC": 1,
|
||||
@@ -54,7 +55,7 @@ exports[`renders all markers 1`] = `
|
||||
<withPropsOnChange(Marker)
|
||||
color="primary"
|
||||
>
|
||||
Pre-Mod
|
||||
Pre-mod
|
||||
</withPropsOnChange(Marker)>
|
||||
</Localized>
|
||||
<Localized
|
||||
@@ -72,7 +73,7 @@ exports[`renders all markers 1`] = `
|
||||
<withPropsOnChange(Marker)
|
||||
color="error"
|
||||
>
|
||||
Banned Word
|
||||
Banned word
|
||||
</withPropsOnChange(Marker)>
|
||||
</Localized>
|
||||
<Localized
|
||||
@@ -82,7 +83,7 @@ exports[`renders all markers 1`] = `
|
||||
color="error"
|
||||
variant="filled"
|
||||
>
|
||||
Suspect Word
|
||||
Suspect word
|
||||
</withPropsOnChange(Marker)>
|
||||
</Localized>
|
||||
<Localized
|
||||
@@ -91,7 +92,7 @@ exports[`renders all markers 1`] = `
|
||||
<withPropsOnChange(Marker)
|
||||
color="error"
|
||||
>
|
||||
Spam Detected
|
||||
Spam detected
|
||||
</withPropsOnChange(Marker)>
|
||||
</Localized>
|
||||
<Localized
|
||||
@@ -103,13 +104,22 @@ exports[`renders all markers 1`] = `
|
||||
Toxic
|
||||
</withPropsOnChange(Marker)>
|
||||
</Localized>
|
||||
<Localized
|
||||
id="moderate-marker-repeatPost"
|
||||
>
|
||||
<withPropsOnChange(Marker)
|
||||
color="error"
|
||||
>
|
||||
Repeat comment
|
||||
</withPropsOnChange(Marker)>
|
||||
</Localized>
|
||||
<Localized
|
||||
id="moderate-marker-recentHistory"
|
||||
>
|
||||
<withPropsOnChange(Marker)
|
||||
color="error"
|
||||
>
|
||||
Recent History
|
||||
Recent history
|
||||
</withPropsOnChange(Marker)>
|
||||
</Localized>
|
||||
<withPropsOnChange(Marker)
|
||||
@@ -161,6 +171,7 @@ exports[`renders some markers 1`] = `
|
||||
"COMMENT_DETECTED_BANNED_WORD": 1,
|
||||
"COMMENT_DETECTED_LINKS": 0,
|
||||
"COMMENT_DETECTED_RECENT_HISTORY": 1,
|
||||
"COMMENT_DETECTED_REPEAT_POST": 0,
|
||||
"COMMENT_DETECTED_SPAM": 0,
|
||||
"COMMENT_DETECTED_SUSPECT_WORD": 0,
|
||||
"COMMENT_DETECTED_TOXIC": 1,
|
||||
@@ -199,7 +210,7 @@ exports[`renders some markers 1`] = `
|
||||
<withPropsOnChange(Marker)
|
||||
color="primary"
|
||||
>
|
||||
Pre-Mod
|
||||
Pre-mod
|
||||
</withPropsOnChange(Marker)>
|
||||
</Localized>
|
||||
<Localized
|
||||
@@ -208,7 +219,7 @@ exports[`renders some markers 1`] = `
|
||||
<withPropsOnChange(Marker)
|
||||
color="error"
|
||||
>
|
||||
Banned Word
|
||||
Banned word
|
||||
</withPropsOnChange(Marker)>
|
||||
</Localized>
|
||||
<Localized
|
||||
@@ -226,7 +237,7 @@ exports[`renders some markers 1`] = `
|
||||
<withPropsOnChange(Marker)
|
||||
color="error"
|
||||
>
|
||||
Recent History
|
||||
Recent history
|
||||
</withPropsOnChange(Marker)>
|
||||
</Localized>
|
||||
<withPropsOnChange(Marker)
|
||||
|
||||
@@ -526,6 +526,7 @@ export const baseComment = createFixture<GQLComment>({
|
||||
COMMENT_DETECTED_SUSPECT_WORD: 0,
|
||||
COMMENT_REPORTED_OFFENSIVE: 0,
|
||||
COMMENT_REPORTED_SPAM: 0,
|
||||
COMMENT_DETECTED_REPEAT_POST: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1550,7 +1550,7 @@ exports[`renders reported queue with comments and load more 1`] = `
|
||||
<span
|
||||
className="Marker-root Marker-colorPrimary Marker-variantRegular"
|
||||
>
|
||||
Pre-Mod
|
||||
Pre-mod
|
||||
</span>
|
||||
<span
|
||||
className="Marker-root Marker-colorError Marker-variantRegular"
|
||||
|
||||
@@ -100,6 +100,7 @@ export function createComment(author?: GQLUser) {
|
||||
COMMENT_DETECTED_SUSPECT_WORD: 0,
|
||||
COMMENT_REPORTED_OFFENSIVE: 0,
|
||||
COMMENT_REPORTED_SPAM: 0,
|
||||
COMMENT_DETECTED_REPEAT_POST: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -147,6 +148,7 @@ export function createComment(author?: GQLUser) {
|
||||
COMMENT_DETECTED_BANNED_WORD: 0,
|
||||
COMMENT_DETECTED_SUSPECT_WORD: 0,
|
||||
COMMENT_DETECTED_PREMOD_USER: 0,
|
||||
COMMENT_DETECTED_REPEAT_POST: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -63,3 +63,9 @@ export const COMMENT_LIMIT_WINDOW_SECONDS = 3;
|
||||
* DEFAULT_SESSION_LENTTH is the length of time in seconds a session is valid for unless configured in tenant.
|
||||
*/
|
||||
export const DEFAULT_SESSION_LENGTH = 7776000;
|
||||
|
||||
/**
|
||||
* COMMENT_REPEAT_POST_TIMESPAN is the length of time in seconds that a previous comment ID is stored for a
|
||||
* user to prevent them from posting the same comment repeatedly.
|
||||
*/
|
||||
export const COMMENT_REPEAT_POST_TIMESPAN = 21600;
|
||||
|
||||
@@ -316,4 +316,10 @@ export enum ERROR_CODES {
|
||||
RAW_QUERY_NOT_AUTHORIZED = "RAW_QUERY_NOT_AUTHORIZED",
|
||||
|
||||
USER_ALREADY_PREMOD = "USER_ALREADY_PREMOD",
|
||||
|
||||
/**
|
||||
* REPEAT_POST is returned if a user attempts to post the same comment more than once
|
||||
* in a row within a given time frame
|
||||
*/
|
||||
REPEAT_POST = "REPEAT_POST",
|
||||
}
|
||||
|
||||
@@ -580,6 +580,16 @@ export class SpamCommentError extends CoralError {
|
||||
}
|
||||
}
|
||||
|
||||
export class RepeatPostCommentError extends CoralError {
|
||||
constructor() {
|
||||
super({
|
||||
code: ERROR_CODES.REPEAT_POST,
|
||||
type: ERROR_TYPES.MODERATION_NUDGE_ERROR,
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class UserAlreadySuspendedError extends CoralError {
|
||||
constructor(until: Date) {
|
||||
super({
|
||||
|
||||
@@ -55,4 +55,5 @@ export const ERROR_TRANSLATIONS: Record<ERROR_CODES, string> = {
|
||||
PERSISTED_QUERY_NOT_FOUND: "error-persistedQueryNotFound",
|
||||
RAW_QUERY_NOT_AUTHORIZED: "error-rawQueryNotAuthorized",
|
||||
USER_ALREADY_PREMOD: "error-userAlreadyPremod",
|
||||
REPEAT_POST: "error-repeatPost",
|
||||
};
|
||||
|
||||
@@ -146,6 +146,11 @@ enum COMMENT_FLAG_DETECTED_REASON {
|
||||
COMMENT_DETECTED_PREMOD_USER is used when a Comment author has been tagged as requiring premoderation
|
||||
"""
|
||||
COMMENT_DETECTED_PREMOD_USER
|
||||
|
||||
"""
|
||||
COMMENT_DETECTED_REPEAT_POST is used when a Comment's text exactly matches a previous recent comment from the same author
|
||||
"""
|
||||
COMMENT_DETECTED_REPEAT_POST
|
||||
}
|
||||
|
||||
"""
|
||||
@@ -163,6 +168,7 @@ enum COMMENT_FLAG_REASON {
|
||||
COMMENT_DETECTED_SUSPECT_WORD
|
||||
COMMENT_DETECTED_RECENT_HISTORY
|
||||
COMMENT_DETECTED_PREMOD_USER
|
||||
COMMENT_DETECTED_REPEAT_POST
|
||||
}
|
||||
|
||||
"""
|
||||
@@ -198,6 +204,7 @@ type FlagReasonActionCounts {
|
||||
COMMENT_DETECTED_SUSPECT_WORD: Int!
|
||||
COMMENT_DETECTED_RECENT_HISTORY: Int!
|
||||
COMMENT_DETECTED_PREMOD_USER: Int!
|
||||
COMMENT_DETECTED_REPEAT_POST: Int!
|
||||
}
|
||||
|
||||
type Flag {
|
||||
|
||||
@@ -57,3 +57,4 @@ error-passwordIncorrect = Incorrect password. Please try again.
|
||||
error-usernameAlreadyUpdated = You may only change your username once every { framework-timeago-time }.
|
||||
error-persistedQueryNotFound = The persisted query with ID { $id } was not found.
|
||||
error-rawQueryNotAuthorized = You are not authorized to execute this query.
|
||||
error-repeatPost = Are you sure? This comment is very similar to your previous comment.
|
||||
|
||||
@@ -21,6 +21,7 @@ Object {
|
||||
"COMMENT_DETECTED_LINKS": 0,
|
||||
"COMMENT_DETECTED_PREMOD_USER": 0,
|
||||
"COMMENT_DETECTED_RECENT_HISTORY": 0,
|
||||
"COMMENT_DETECTED_REPEAT_POST": 0,
|
||||
"COMMENT_DETECTED_SPAM": 0,
|
||||
"COMMENT_DETECTED_SUSPECT_WORD": 0,
|
||||
"COMMENT_DETECTED_TOXIC": 0,
|
||||
|
||||
@@ -49,7 +49,10 @@ import {
|
||||
import { AugmentedRedis } from "coral-server/services/redis";
|
||||
import { Request } from "coral-server/types/express";
|
||||
|
||||
import { updateUserLastWroteCommentTimestamp } from "../users";
|
||||
import {
|
||||
updateUserLastCommentID,
|
||||
updateUserLastWroteCommentTimestamp,
|
||||
} from "../users";
|
||||
import { addCommentActions, CreateAction } from "./actions";
|
||||
import { calculateCounts, calculateCountsDiff } from "./moderation/counts";
|
||||
import { PhaseResult, processForModeration } from "./pipeline";
|
||||
@@ -186,6 +189,8 @@ export async function create(
|
||||
now
|
||||
);
|
||||
|
||||
await updateUserLastCommentID(redis, tenant, author, comment.id);
|
||||
|
||||
// Pull the revision out.
|
||||
const revision = getLatestRevision(comment);
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { preModerate } from "./preModerate";
|
||||
import { premodUser } from "./preModerateUser";
|
||||
import { purify } from "./purify";
|
||||
import { recentCommentHistory } from "./recentCommentHistory";
|
||||
import { repeatPost } from "./repeatPost";
|
||||
import { spam } from "./spam";
|
||||
import { staff } from "./staff";
|
||||
import { storyClosed } from "./storyClosed";
|
||||
@@ -25,6 +26,7 @@ export const moderationPhases: IntermediateModerationPhase[] = [
|
||||
commentingDisabled,
|
||||
linkify,
|
||||
purify,
|
||||
repeatPost,
|
||||
wordList,
|
||||
staff,
|
||||
toxic,
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { RepeatPostCommentError } from "coral-server/errors";
|
||||
import {
|
||||
GQLCOMMENT_FLAG_REASON,
|
||||
GQLCOMMENT_STATUS,
|
||||
} from "coral-server/graph/tenant/schema/__generated__/types";
|
||||
import logger from "coral-server/logger";
|
||||
import { ACTION_TYPE } from "coral-server/models/action/comment";
|
||||
import { getLatestRevision } from "coral-server/models/comment/helpers";
|
||||
import {
|
||||
IntermediateModerationPhase,
|
||||
IntermediatePhaseResult,
|
||||
} from "coral-server/services/comments/pipeline";
|
||||
import { retrieveUserLastComment } from "coral-server/services/users";
|
||||
|
||||
export const repeatPost: IntermediateModerationPhase = async ({
|
||||
story,
|
||||
mongo,
|
||||
tenant,
|
||||
comment,
|
||||
author,
|
||||
req,
|
||||
nudge,
|
||||
redis,
|
||||
}): Promise<IntermediatePhaseResult | void> => {
|
||||
const log = logger.child(
|
||||
{
|
||||
tenantID: tenant.id,
|
||||
},
|
||||
true
|
||||
);
|
||||
|
||||
if (!comment.body) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
log.trace("checking comment for repeat content");
|
||||
|
||||
const lastComment = await retrieveUserLastComment(
|
||||
mongo,
|
||||
redis,
|
||||
tenant,
|
||||
author
|
||||
);
|
||||
|
||||
if (!lastComment) {
|
||||
return;
|
||||
}
|
||||
|
||||
const revision = getLatestRevision(lastComment);
|
||||
const isRepeatComment = revision.body === comment.body;
|
||||
|
||||
if (isRepeatComment) {
|
||||
log.trace({ isRepeatComment }, "comment contains repeat content");
|
||||
|
||||
// Throw an error if we're nudging instead of recording.
|
||||
if (nudge) {
|
||||
throw new RepeatPostCommentError();
|
||||
}
|
||||
|
||||
return {
|
||||
status: GQLCOMMENT_STATUS.SYSTEM_WITHHELD,
|
||||
actions: [
|
||||
{
|
||||
userID: null,
|
||||
actionType: ACTION_TYPE.FLAG,
|
||||
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_REPEAT_POST,
|
||||
},
|
||||
],
|
||||
metadata: {},
|
||||
};
|
||||
}
|
||||
|
||||
log.trace({ isRepeatComment }, "comment is not repeated");
|
||||
} catch (err) {
|
||||
// Rethrow any RepeatPostError.
|
||||
if (err instanceof RepeatPostCommentError) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
log.error(
|
||||
{ err },
|
||||
"could not determine if comment contained duplicate content"
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -4,6 +4,7 @@ import { Db } from "mongodb";
|
||||
import {
|
||||
ALLOWED_USERNAME_CHANGE_FREQUENCY,
|
||||
COMMENT_LIMIT_WINDOW_SECONDS,
|
||||
COMMENT_REPEAT_POST_TIMESPAN,
|
||||
DOWNLOAD_LIMIT_TIMEFRAME,
|
||||
} from "coral-common/constants";
|
||||
import { SCHEDULED_DELETION_TIMESPAN_DAYS } from "coral-common/constants";
|
||||
@@ -31,6 +32,7 @@ import {
|
||||
GQLUSER_ROLE,
|
||||
} from "coral-server/graph/tenant/schema/__generated__/types";
|
||||
import logger from "coral-server/logger";
|
||||
import { Comment, retrieveComment } from "coral-server/models/comment";
|
||||
import { Tenant } from "coral-server/models/tenant";
|
||||
import {
|
||||
banUser,
|
||||
@@ -1190,6 +1192,13 @@ function userLastWroteCommentTimestampKey(
|
||||
return `${tenant.id}:lastCommentTimestamp:${user.id}`;
|
||||
}
|
||||
|
||||
function userLastCommentIDKey(
|
||||
tenant: Pick<Tenant, "id">,
|
||||
user: Pick<User, "id">
|
||||
) {
|
||||
return `${tenant.id}:lastCommentID:${user.id}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* retrieveUserLastWroteCommentTimestamp will return the timestamp (if set) that
|
||||
* the user last wrote a comment on. This will return null if the comment was
|
||||
@@ -1245,3 +1254,46 @@ export async function updateUserLastWroteCommentTimestamp(
|
||||
throw new RateLimitExceeded("createComment", 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* updateUserLastCommentID will update the id of the users most recent comment.
|
||||
*
|
||||
* @param redis the Redis instance that Coral interacts with
|
||||
* @param tenant the Tenant to operate on
|
||||
* @param user the User that we're setting the limit for
|
||||
* @param commentID the id of the comment
|
||||
*/
|
||||
export async function updateUserLastCommentID(
|
||||
redis: AugmentedRedis,
|
||||
tenant: Tenant,
|
||||
user: User,
|
||||
commentID: string
|
||||
) {
|
||||
const key = userLastCommentIDKey(tenant, user);
|
||||
|
||||
await redis.set(key, commentID, "EX", COMMENT_REPEAT_POST_TIMESPAN);
|
||||
}
|
||||
|
||||
/**
|
||||
* retrieveUserLastComment will return the id (if set) of the comment that
|
||||
* the user last wrote. This will return null if the user has not made a comment
|
||||
* within the CURRENT_REPEAT_POST_TIMESPAN.
|
||||
*
|
||||
* @param mongo the db
|
||||
* @param redis the Redis instance that Coral interacts with
|
||||
* @param tenant the Tenant to operate on
|
||||
* @param user the User that we're looking up the limit for
|
||||
*/
|
||||
export async function retrieveUserLastComment(
|
||||
mongo: Db,
|
||||
redis: AugmentedRedis,
|
||||
tenant: Tenant,
|
||||
user: User
|
||||
): Promise<Readonly<Comment> | null> {
|
||||
const id: string | null = await redis.get(userLastCommentIDKey(tenant, user));
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return retrieveComment(mongo, tenant.id, id);
|
||||
}
|
||||
|
||||
@@ -387,16 +387,17 @@ moderate-navigation-pending = Pending
|
||||
moderate-navigation-unmoderated = unmoderated
|
||||
moderate-navigation-rejected = rejected
|
||||
|
||||
moderate-marker-preMod = Pre-Mod
|
||||
moderate-marker-preMod = Pre-mod
|
||||
moderate-marker-link = Link
|
||||
moderate-marker-bannedWord = Banned Word
|
||||
moderate-marker-suspectWord = Suspect Word
|
||||
moderate-marker-bannedWord = Banned word
|
||||
moderate-marker-suspectWord = Suspect word
|
||||
moderate-marker-spam = Spam
|
||||
moderate-marker-spamDetected = Spam Detected
|
||||
moderate-marker-spamDetected = Spam detected
|
||||
moderate-marker-toxic = Toxic
|
||||
moderate-marker-recentHistory = Recent History
|
||||
moderate-marker-bodyCount = Body Count
|
||||
moderate-marker-recentHistory = Recent history
|
||||
moderate-marker-bodyCount = Body count
|
||||
moderate-marker-offensive = Offensive
|
||||
moderate-marker-repeatPost = Repeat comment
|
||||
|
||||
moderate-markers-details = Details
|
||||
moderate-flagDetails-offensive = Offensive
|
||||
|
||||
Reference in New Issue
Block a user