[CORL-404] Recent Comment History (#2354)

* feat: initial support for auto pre-moderation

* chore: refactor collection access

* fix: linting

* fix: rebasing issue

* fix: exported helpers

* feat: added extensions, lintd

* fix: rebase fix

* feat: renamed automaticPreModeration to recentCommentHistory

* feat: initial implementation of admin config

* feat: support recent history markers

* feat: rename visible to published

* feat: reworked history drawer

* chore: extracted tooltip

* feat: implemented user drawer

* fix: fixed translation key

* fix: resolved issue with NaN
This commit is contained in:
Wyatt Johnson
2019-08-08 18:18:18 +00:00
committed by GitHub
parent ff964b54a3
commit 4c65d43954
103 changed files with 2856 additions and 1917 deletions
+8
View File
@@ -0,0 +1,8 @@
{
"recommendations": [
"ms-vscode.vscode-typescript-tslint-plugin",
"kumar-harsh.graphql-for-vscode",
"editorconfig.editorconfig",
"ms-azuretools.vscode-cosmosdb"
]
}
+6 -5
View File
@@ -1,5 +1,4 @@
{
"editor.formatOnSave": true,
"files.associations": {
"*.css": "postcss"
},
@@ -12,9 +11,11 @@
".vs": true
},
"tslint.exclude": "**/node_modules/**",
"tslint.autoFixOnSave": true,
"tslint.jsEnable": true,
"tslint.nodePath": "node_modules/.bin/tslint",
"typescript.tsdk": "./node_modules/typescript/lib",
"postcss.validate": false
"typescript.tsdk": "node_modules/typescript/lib",
"postcss.validate": false,
"javascript.preferences.importModuleSpecifier": "non-relative",
"editor.codeActionsOnSave": {
"source.fixAll.tslint": true
}
}
+1 -1
View File
@@ -23643,7 +23643,7 @@
"dependencies": {
"async": {
"version": "1.5.2",
"resolved": "http://registry.npmjs.org/async/-/async-1.5.2.tgz",
"resolved": "https://registry.npmjs.org/async/-/async-1.5.2.tgz",
"integrity": "sha1-7GphrlZIDAw8skHJVhjiCJL5Zyo=",
"dev": true
}
+1 -1
View File
@@ -19,7 +19,7 @@ async function main() {
contextType: "TenantContext",
importStatements: [
'import TenantContext from "coral-server/graph/tenant/context";',
'import { Cursor } from "coral-server/models/helpers/connection";',
'import { Cursor } from "coral-server/models/helpers";',
],
customScalarType: { Cursor: "Cursor", Time: "Date" },
},
@@ -19,7 +19,7 @@ it("renders all markers", () => {
reasons: {
COMMENT_DETECTED_TOXIC: 1,
COMMENT_DETECTED_SPAM: 1,
COMMENT_DETECTED_TRUST: 1,
COMMENT_DETECTED_RECENT_HISTORY: 1,
COMMENT_DETECTED_LINKS: 1,
COMMENT_DETECTED_BANNED_WORD: 1,
COMMENT_DETECTED_SUSPECT_WORD: 1,
@@ -59,7 +59,7 @@ it("renders some markers", () => {
reasons: {
COMMENT_DETECTED_TOXIC: 1,
COMMENT_DETECTED_SPAM: 0,
COMMENT_DETECTED_TRUST: 1,
COMMENT_DETECTED_RECENT_HISTORY: 1,
COMMENT_DETECTED_LINKS: 0,
COMMENT_DETECTED_BANNED_WORD: 1,
COMMENT_DETECTED_SUSPECT_WORD: 0,
@@ -72,9 +72,9 @@ const markers: Array<
)) ||
null,
c =>
(c.revision.actionCounts.flag.reasons.COMMENT_DETECTED_TRUST && (
<Localized id="moderate-marker-karma" key={keyCounter++}>
<Marker color="error">Karma</Marker>
(c.revision.actionCounts.flag.reasons.COMMENT_DETECTED_RECENT_HISTORY && (
<Localized id="moderate-marker-recentHistory" key={keyCounter++}>
<Marker color="error">Recent History</Marker>
</Localized>
)) ||
null,
@@ -141,7 +141,7 @@ const enhanced = withFragmentContainer<MarkersContainerProps>({
reasons {
COMMENT_DETECTED_TOXIC
COMMENT_DETECTED_SPAM
COMMENT_DETECTED_TRUST
COMMENT_DETECTED_RECENT_HISTORY
COMMENT_DETECTED_LINKS
COMMENT_DETECTED_BANNED_WORD
COMMENT_DETECTED_SUSPECT_WORD
@@ -139,7 +139,11 @@ const ModerateCardContainer: FunctionComponent<Props> = ({
<FadeInTransition active={Boolean(comment.enteredLive)}>
<ModerateCard
id={comment.id}
username={comment.author!.username!}
username={
comment.author && comment.author.username
? comment.author.username
: ""
}
createdAt={comment.createdAt}
body={comment.body!}
inReplyTo={comment.parent && comment.parent.author!.username!}
@@ -12,10 +12,10 @@ exports[`renders all markers 1`] = `
"reasons": Object {
"COMMENT_DETECTED_BANNED_WORD": 1,
"COMMENT_DETECTED_LINKS": 1,
"COMMENT_DETECTED_RECENT_HISTORY": 1,
"COMMENT_DETECTED_SPAM": 1,
"COMMENT_DETECTED_SUSPECT_WORD": 1,
"COMMENT_DETECTED_TOXIC": 1,
"COMMENT_DETECTED_TRUST": 1,
"COMMENT_REPORTED_OFFENSIVE": 2,
"COMMENT_REPORTED_SPAM": 3,
},
@@ -99,12 +99,12 @@ exports[`renders all markers 1`] = `
</withPropsOnChange(Marker)>
</Localized>
<Localized
id="moderate-marker-karma"
id="moderate-marker-recentHistory"
>
<withPropsOnChange(Marker)
color="error"
>
Karma
Recent History
</withPropsOnChange(Marker)>
</Localized>
<withPropsOnChange(Marker)
@@ -152,10 +152,10 @@ exports[`renders some markers 1`] = `
"reasons": Object {
"COMMENT_DETECTED_BANNED_WORD": 1,
"COMMENT_DETECTED_LINKS": 0,
"COMMENT_DETECTED_RECENT_HISTORY": 1,
"COMMENT_DETECTED_SPAM": 0,
"COMMENT_DETECTED_SUSPECT_WORD": 0,
"COMMENT_DETECTED_TOXIC": 1,
"COMMENT_DETECTED_TRUST": 1,
"COMMENT_REPORTED_OFFENSIVE": 2,
"COMMENT_REPORTED_SPAM": 0,
},
@@ -211,12 +211,12 @@ exports[`renders some markers 1`] = `
</withPropsOnChange(Marker)>
</Localized>
<Localized
id="moderate-marker-karma"
id="moderate-marker-recentHistory"
>
<withPropsOnChange(Marker)
color="error"
>
Karma
Recent History
</withPropsOnChange(Marker)>
</Localized>
<withPropsOnChange(Marker)
@@ -0,0 +1,29 @@
.title {
text-transform: uppercase;
}
.subTitle {
color: var(--palette-grey-main);
font-size: calc(14rem / var(--rem-base));
}
.info {
display: inline-block;
}
.amount {
font-size: calc(24rem / var(--rem-base));
}
.amountLabel {
font-size: calc(14rem / var(--rem-base));
}
.triggered {
color: var(--palette-error-main);
}
.tooltip {
color: var(--palette-text-primary);
padding: 3px;
}
@@ -0,0 +1,116 @@
import cn from "classnames";
import { Localized } from "fluent-react/compat";
import React, { FunctionComponent } from "react";
import { reduceSeconds, UNIT } from "coral-framework/lib/i18n";
import {
Box,
Flex,
Tooltip,
TooltipButton,
Typography,
} from "coral-ui/components";
import styles from "./RecentHistory.css";
interface Props {
triggered: boolean;
timeFrame: number;
rejectionRate: number;
submitted: number;
}
const RecentHistory: FunctionComponent<Props> = ({
triggered,
timeFrame,
rejectionRate,
submitted,
}) => {
const { scaled, unit } = reduceSeconds(timeFrame, [UNIT.DAYS]);
return (
<Box mt={3}>
<Localized id="moderate-user-drawer-recent-history-title">
<Typography className={styles.title} variant="bodyCopyBold">
Recent comment history
</Typography>
</Localized>
<Localized
id="moderate-user-drawer-recent-history-calculated"
$unit={unit}
$value={scaled}
>
<Typography className={styles.subTitle} variant="bodyCopy">
Calculated over the last {scaled} {unit}
</Typography>
</Localized>
<Box mt={1}>
<Box className={styles.info} mr={4}>
<Typography
className={cn(styles.amount, {
[styles.triggered]: triggered,
})}
variant="bodyCopyBold"
>
{Math.round(rejectionRate * 100)}%
</Typography>
<Flex alignItems="center">
<Localized id="moderate-user-drawer-recent-history-rejected">
<Typography
className={cn(styles.amountLabel, {
[styles.triggered]: triggered,
})}
variant="bodyCopy"
container="span"
>
Rejected
</Typography>
</Localized>
<Tooltip
id="recentCommentHistory-rejectionPopover"
title={
<Localized id="moderate-user-drawer-recent-history-tooltip-title">
<span>How is this calculated?</span>
</Localized>
}
body={
<Localized id="moderate-user-drawer-recent-history-tooltip-body">
<span>
Rejected comments divided by the sum of rejected and
published comments, during the recent comment history time
frame.
</span>
</Localized>
}
button={({ toggleVisibility, ref }) => (
<Localized
id="moderate-user-drawer-recent-history-tooltip-button"
attrs={{ "aria-label": true }}
>
<TooltipButton
className={styles.tooltip}
aria-label="Toggle recent comment history tooltip"
toggleVisibility={toggleVisibility}
ref={ref}
/>
</Localized>
)}
/>
</Flex>
</Box>
<Box className={styles.info}>
<Typography className={styles.amount} variant="bodyCopyBold">
{submitted}
</Typography>
<Localized id="moderate-user-drawer-recent-history-tooltip-submitted">
<Typography className={styles.amountLabel} variant="bodyCopy">
Submitted
</Typography>
</Localized>
</Box>
</Box>
</Box>
);
};
export default RecentHistory;
@@ -0,0 +1,82 @@
import React, { FunctionComponent, useMemo } from "react";
import { RecentHistoryContainer_settings } from "coral-admin/__generated__/RecentHistoryContainer_settings.graphql";
import { RecentHistoryContainer_user } from "coral-admin/__generated__/RecentHistoryContainer_user.graphql";
import { graphql, withFragmentContainer } from "coral-framework/lib/relay";
import { GQLCOMMENT_STATUS } from "coral-framework/schema";
import RecentHistory from "./RecentHistory";
const PUBLISHED_STATUSES = [GQLCOMMENT_STATUS.NONE, GQLCOMMENT_STATUS.APPROVED];
interface Props {
user: RecentHistoryContainer_user;
settings: RecentHistoryContainer_settings;
}
const RecentHistoryContainer: FunctionComponent<Props> = ({
user,
settings,
}) => {
const submitted = useMemo(
() =>
Object.keys(user.recentCommentHistory.statuses).reduce(
(acc, key: keyof typeof user.recentCommentHistory.statuses) =>
user.recentCommentHistory.statuses[key] + acc,
0
),
[user]
);
const rejectionRate = useMemo(() => {
const published = PUBLISHED_STATUSES.reduce(
(acc, status) => user.recentCommentHistory.statuses[status] + acc,
0
);
const rejected =
user.recentCommentHistory.statuses[GQLCOMMENT_STATUS.REJECTED];
const total = published + rejected;
if (total === 0) {
return 0;
}
return rejected / total;
}, [user]);
const triggered =
settings.recentCommentHistory.enabled &&
rejectionRate >= settings.recentCommentHistory.triggerRejectionRate;
return (
<RecentHistory
triggered={triggered}
timeFrame={settings.recentCommentHistory.timeFrame}
rejectionRate={rejectionRate}
submitted={submitted}
/>
);
};
const enhanced = withFragmentContainer<Props>({
user: graphql`
fragment RecentHistoryContainer_user on User {
recentCommentHistory {
statuses {
NONE
APPROVED
REJECTED
PREMOD
SYSTEM_WITHHELD
}
}
}
`,
settings: graphql`
fragment RecentHistoryContainer_settings on Settings {
recentCommentHistory {
enabled
timeFrame
triggerRejectionRate
}
}
`,
})(RecentHistoryContainer);
export default enhanced;
@@ -0,0 +1,14 @@
.root {
position: fixed;
top: 0;
right: 0;
width: 624px;
height: 100%;
display: flex;
flex-flow: column nowrap;
flex-direction: column;
background-color: var(--palette-common-white);
}
@@ -0,0 +1,38 @@
import React, { FunctionComponent } from "react";
import { Card, Modal } from "coral-ui/components";
import UserHistoryDrawerQuery from "./UserHistoryDrawerQuery";
import styles from "./UserHistoryDrawer.css";
interface UserHistoryDrawerProps {
open: boolean;
onClose: () => void;
userID?: string;
}
const UserHistoryDrawer: FunctionComponent<UserHistoryDrawerProps> = ({
open,
onClose,
userID,
}) => {
return (
<Modal open={open} onClose={onClose}>
{({ firstFocusableRef, lastFocusableRef }) => (
<Card className={styles.root}>
{userID && (
<UserHistoryDrawerQuery
userID={userID}
onClose={onClose}
firstFocusableRef={firstFocusableRef}
lastFocusableRef={lastFocusableRef}
/>
)}
</Card>
)}
</Modal>
);
};
export default UserHistoryDrawer;
@@ -1,14 +1,95 @@
.root {
position: fixed;
top:0;
right: 0;
.comments {
flex: auto;
overflow: hidden;
}
width: 624px;
height: 100%;
.close {
position: absolute;
width: 40px;
height: 40px;
left: -40px;
display: flex;
flex-flow: column nowrap;
flex-direction: column;
padding: 0px;
background-color: var(--palette-common-white);
}
border-width: 2px;
border-right-width: 0px;
border-style: solid;
border-color: var(--palette-grey-main);
border-radius: 4px 0px 0px 4px;
}
.username {
font-family: var(--font-family-serif);
font-size: calc(24rem / var(--rem-base));
font-weight: 500;
font-family: var(--font-family-serif);
line-height: calc(36em / 24);
letter-spacing: calc(0.2em / 24);
margin-bottom: var(--spacing-1);
}
.userDetail {
margin-bottom: var(--spacing-1);
}
.userDetailValue {
margin-right: var(--spacing-2);
}
.icon {
margin-right: var(--spacing-2);
}
.copy {
border: 1px solid var(--palette-primary-main);
background-color: transparent;
color: var(--palette-primary-main);
padding-top: 2px;
padding-bottom: 2px;
&:active {
background-color: var(--palette-primary-lightest);
border-color: var(--palette-primary-main);
color: var(--palette-primary-main);
}
}
hr {
border: 0;
border-bottom: 1px solid var(--palette-divider);
padding-top: 1px;
width: 100%;
}
.divider {
border-bottom: 1px solid var(--palette-grey-lighter);
}
.userStatus {
margin-top: var(--spacing-1);
margin-bottom: var(--spacing-1);
}
.userStatusLabel {
display: inline-block;
margin-right: var(--spacing-1);
font-family: var(--font-family-sans-serif);
font-style: normal;
font-weight: 600;
font-size: 16px;
line-height: 14px;
}
.userStatusChange {
display: inline-block;
border-style: solid;
border-color: var(--palette-grey-lighter);
border-width: 1px;
border-radius: var(--round-corners);
padding-left: var(--spacing-2);
padding-right: var(--spacing-1);
}
@@ -1,35 +1,153 @@
import { Localized } from "fluent-react/compat";
import React, { FunctionComponent } from "react";
import { Card, Modal } from "coral-ui/components";
import { UserHistoryDrawerContainer_settings } from "coral-admin/__generated__/UserHistoryDrawerContainer_settings.graphql";
import { UserHistoryDrawerContainer_user } from "coral-admin/__generated__/UserHistoryDrawerContainer_user.graphql";
import { UserStatusChangeContainer } from "coral-admin/components/UserStatus";
import { CopyButton } from "coral-framework/components";
import { useCoralContext } from "coral-framework/lib/bootstrap";
import { graphql, withFragmentContainer } from "coral-framework/lib/relay";
import { Button, Flex, Icon, Typography } from "coral-ui/components";
import RecentHistoryContainer from "./RecentHistoryContainer";
import Tabs from "./Tabs";
import UserStatusDetailsContainer from "./UserStatusDetailsContainer";
import styles from "./UserHistoryDrawerContainer.css";
import UserHistoryDrawerQuery from "./UserHistoryDrawerQuery";
interface UserHistoryDrawerContainerProps {
open: boolean;
interface Props {
user: UserHistoryDrawerContainer_user;
settings: UserHistoryDrawerContainer_settings;
onClose: () => void;
userID?: string;
}
const UserHistoryDrawerContainer: FunctionComponent<
UserHistoryDrawerContainerProps
> = ({ open, onClose, userID }) => {
const UserHistoryDrawerContainer: FunctionComponent<Props> = ({
settings,
user,
onClose,
}) => {
const { locales } = useCoralContext();
const formatter = new Intl.DateTimeFormat(locales, {
month: "long",
day: "numeric",
year: "numeric",
});
return (
<Modal open={open} onClose={onClose}>
{({ firstFocusableRef, lastFocusableRef }) => (
<Card className={styles.root}>
{userID && (
<UserHistoryDrawerQuery
userID={userID}
onClose={onClose}
firstFocusableRef={firstFocusableRef}
lastFocusableRef={lastFocusableRef}
/>
)}
</Card>
)}
</Modal>
<>
<Button className={styles.close} onClick={onClose}>
<Icon size="md">close</Icon>
</Button>
<Flex className={styles.username}>
<span>{user.username}</span>
</Flex>
<div className={styles.userStatus}>
<Flex alignItems="center" itemGutter="half">
<div className={styles.userStatusLabel}>
<Typography variant="bodyCopyBold" container="div">
<Flex alignItems="center" itemGutter="half">
<Localized id="moderate-user-drawer-status-label">
Status:
</Localized>
</Flex>
</Typography>
</div>
<div className={styles.userStatusChange}>
<UserStatusChangeContainer settings={settings} user={user} />
</div>
<UserStatusDetailsContainer user={user} />
</Flex>
</div>
<div>
<Flex alignItems="center" className={styles.userDetail}>
<Localized id="moderate-user-drawer-email" attrs={{ title: true }}>
<Icon size="sm" className={styles.icon} title="Email address">
mail_outline
</Icon>
</Localized>
<Typography
variant="bodyCopy"
container="span"
className={styles.userDetailValue}
>
{user.email}
</Typography>
<CopyButton
text={user.email!}
variant="regular"
className={styles.copy}
/>
</Flex>
<Flex alignItems="center" className={styles.userDetail}>
<Localized
id="moderate-user-drawer-created-at"
attrs={{ title: true }}
>
<Icon
size="sm"
className={styles.icon}
title="Account creation date"
>
date_range
</Icon>
</Localized>
<Typography variant="bodyCopy" container="span">
{formatter.format(new Date(user.createdAt))}
</Typography>
</Flex>
<Flex alignItems="center" className={styles.userDetail}>
<Localized
id="moderate-user-drawer-member-id"
attrs={{ title: true }}
>
<Icon size="sm" className={styles.icon} title="Member ID">
people_outline
</Icon>
</Localized>
<Typography
variant="bodyCopy"
container="span"
className={styles.userDetailValue}
>
{user.id}
</Typography>
<CopyButton
text={user.id}
variant="regular"
className={styles.copy}
/>
</Flex>
<RecentHistoryContainer user={user} settings={settings} />
</div>
<hr className={styles.divider} />
<div className={styles.comments}>
<Tabs userID={user.id} />
</div>
</>
);
};
export default UserHistoryDrawerContainer;
const enhanced = withFragmentContainer<Props>({
user: graphql`
fragment UserHistoryDrawerContainer_user on User {
...UserStatusChangeContainer_user
...UserStatusDetailsContainer_user
...RecentHistoryContainer_user
id
username
email
createdAt
}
`,
settings: graphql`
fragment UserHistoryDrawerContainer_settings on Settings {
...RecentHistoryContainer_settings
...UserStatusChangeContainer_settings
organization {
name
}
}
`,
})(UserHistoryDrawerContainer);
export default enhanced;
@@ -1,121 +1,6 @@
.root {
position: fixed;
top:0;
right: 0;
width: 624px;
height: 100%;
display: flex;
flex-flow: column nowrap;
flex-direction: column;
background-color: var(--palette-common-white);
}
.comments {
flex: auto;
overflow: hidden;
}
.close {
position: absolute;
width: 40px;
height: 40px;
left: -40px;
padding: 0px;
background-color: var(--palette-common-white);
border-width: 2px;
border-right-width: 0px;
border-style: solid;
border-color: var(--palette-grey-main);
border-radius: 4px 0px 0px 4px;
}
.username {
font-family: var(--font-family-serif);
font-size: calc(24rem / var(--rem-base));
font-weight: 500;
font-family: var(--font-family-serif);
line-height: calc(36em / 24);
letter-spacing: calc(0.2em / 24);
margin-bottom: var(--spacing-1);
}
.userDetails {
margin-bottom: var(--spacing-3);
}
.userDetail {
margin-bottom: var(--spacing-1);
}
.userDetailValue {
margin-right: var(--spacing-2);
}
.icon {
margin-right: var(--spacing-2);
}
.copy {
border: 1px solid var(--palette-primary-main);
background-color: transparent;
color: var(--palette-primary-main);
padding-top: 2px;
padding-bottom: 2px;
&:active {
background-color: var(--palette-primary-lightest);
border-color: var(--palette-primary-main);
color: var(--palette-primary-main);
}
}
.callout {
width: 100%;
font-family: var(--font-family-sans-serif);
align-content: center;
text-align: center;
}
hr {
border: 0;
border-bottom: 1px solid var(--palette-divider);
padding-top: 1px;
width: 100%;
}
.divider {
border-bottom: 1px solid var(--palette-grey-lighter);
}
.userStatus {
margin-top: var(--spacing-1);
margin-bottom: var(--spacing-1);
}
.userStatusLabel {
display: inline-block;
margin-right: var(--spacing-1);
font-family: var(--font-family-sans-serif);
font-style: normal;
font-weight: 600;
font-size: 16px;
line-height: 14px;
}
.userStatusChange {
display: inline-block;
border-style: solid;
border-color: var(--palette-grey-lighter);
border-width: 1px;
border-radius: var(--round-corners);
padding-left: var(--spacing-2);
padding-right: var(--spacing-1);
}
@@ -2,23 +2,11 @@ import { Localized } from "fluent-react/compat";
import React, { FunctionComponent } from "react";
import { ReadyState } from "react-relay";
import { UserStatusChangeContainer } from "coral-admin/components/UserStatus";
import { CopyButton } from "coral-framework/components";
import { useCoralContext } from "coral-framework/lib/bootstrap";
import { graphql, QueryRenderer } from "coral-framework/lib/relay";
import { UserHistoryDrawerQuery as QueryTypes } from "coral-admin/__generated__/UserHistoryDrawerQuery.graphql";
import {
Button,
CallOut,
Flex,
Icon,
Spinner,
Typography,
} from "coral-ui/components";
import { graphql, QueryRenderer } from "coral-framework/lib/relay";
import { CallOut, Spinner } from "coral-ui/components";
import Tabs from "./Tabs";
import UserStatusDetailsContainer from "./UserStatusDetailsContainer";
import UserHistoryDrawerContainer from "./UserHistoryDrawerContainer";
import styles from "./UserHistoryDrawerQuery.css";
@@ -33,36 +21,21 @@ const UserHistoryDrawerQuery: FunctionComponent<Props> = ({
userID,
onClose,
}) => {
const { locales } = useCoralContext();
const formatter = new Intl.DateTimeFormat(locales, {
month: "long",
day: "numeric",
year: "numeric",
});
return (
<QueryRenderer<QueryTypes>
query={graphql`
query UserHistoryDrawerQuery($userID: ID!) {
user(id: $userID) {
id
username
email
createdAt
...UserStatusDetailsContainer_user
...UserStatusChangeContainer_user
...UserHistoryDrawerContainer_user
}
settings {
organization {
name
}
...UserStatusChangeContainer_settings
...UserHistoryDrawerContainer_settings
}
}
`}
variables={{ userID }}
cacheConfig={{ force: true }}
render={({ error, props }: ReadyState<QueryTypes["response"]>) => {
render={({ props }: ReadyState<QueryTypes["response"]>) => {
if (!props) {
return (
<div className={styles.root}>
@@ -73,9 +46,9 @@ const UserHistoryDrawerQuery: FunctionComponent<Props> = ({
if (!props.user) {
return (
<div className={styles.callout}>
<div className={styles.root}>
<CallOut>
<Localized id="moderate-user-drawer-user-not-found ">
<Localized id="moderate-user-drawer-user-not-found">
User not found.
</Localized>
</CallOut>
@@ -83,105 +56,12 @@ const UserHistoryDrawerQuery: FunctionComponent<Props> = ({
);
}
const { user, settings } = props;
return (
<>
<Button className={styles.close} onClick={onClose}>
<Icon size="md">close</Icon>
</Button>
<Flex className={styles.username}>
<span>{user.username}</span>
</Flex>
<div className={styles.userStatus}>
<Flex alignItems="center" itemGutter="half">
<div className={styles.userStatusLabel}>
<Typography variant="bodyCopyBold" container="div">
<Flex alignItems="center" itemGutter="half">
<Localized id="moderate-user-drawer-status-label">
Status:
</Localized>
</Flex>
</Typography>
</div>
<div className={styles.userStatusChange}>
<UserStatusChangeContainer
settings={settings}
user={user}
fullWidth={false}
/>
</div>
<UserStatusDetailsContainer user={user} />
</Flex>
</div>
<div className={styles.userDetails}>
<Flex alignItems="center" className={styles.userDetail}>
<Localized
id="moderate-user-drawer-email"
attrs={{ title: true }}
>
<Icon size="sm" className={styles.icon} title="Email address">
mail_outline
</Icon>
</Localized>
<Typography
variant="bodyCopy"
container="span"
className={styles.userDetailValue}
>
{user.email}
</Typography>
<CopyButton
text={user.email!}
variant="regular"
className={styles.copy}
/>
</Flex>
<Flex alignItems="center" className={styles.userDetail}>
<Localized
id="moderate-user-drawer-created-at"
attrs={{ title: true }}
>
<Icon
size="sm"
className={styles.icon}
title="Account creation date"
>
date_range
</Icon>
</Localized>
<Typography variant="bodyCopy" container="span">
{formatter.format(new Date(user.createdAt))}
</Typography>
</Flex>
<Flex alignItems="center" className={styles.userDetail}>
<Localized
id="moderate-user-drawer-member-id"
attrs={{ title: true }}
>
<Icon size="sm" className={styles.icon} title="Member ID">
people_outline
</Icon>
</Localized>
<Typography
variant="bodyCopy"
container="span"
className={styles.userDetailValue}
>
{user.id}
</Typography>
<CopyButton
text={user.id}
variant="regular"
className={styles.copy}
/>
</Flex>
</div>
<hr className={styles.divider} />
<div className={styles.comments}>
<Tabs userID={user.id} />
</div>
</>
<UserHistoryDrawerContainer
onClose={onClose}
user={props.user}
settings={props.settings}
/>
);
}}
/>
@@ -0,0 +1 @@
export { default } from "./UserHistoryDrawer";
@@ -27,6 +27,18 @@ const ApproveCommentMutation = createMutation(
comment {
id
status
author {
id
recentCommentHistory {
statuses {
NONE
APPROVED
REJECTED
PREMOD
SYSTEM_WITHHELD
}
}
}
statusHistory(first: 1) {
edges {
node {
@@ -27,6 +27,18 @@ const RejectCommentMutation = createMutation(
comment {
id
status
author {
id
recentCommentHistory {
statuses {
NONE
APPROVED
REJECTED
PREMOD
SYSTEM_WITHHELD
}
}
}
statusHistory(first: 1) {
edges {
node {
@@ -4,7 +4,7 @@ import React, { FunctionComponent, useCallback, useState } from "react";
import { PropTypesOf } from "coral-framework/types";
import AutoLoadMore from "coral-admin/components/AutoLoadMore";
import UserHistoryDrawerContainer from "coral-admin/components/UserHistoryDrawer/UserHistoryDrawerContainer";
import UserHistoryDrawer from "coral-admin/components/UserHistoryDrawer";
import {
Table,
TableBody,
@@ -108,7 +108,7 @@ const UserTable: FunctionComponent<Props> = ({
/>
</Flex>
)}
<UserHistoryDrawerContainer
<UserHistoryDrawer
userID={userDrawerUserID}
open={userDrawerVisible}
onClose={onHideUserDrawer}
@@ -6,12 +6,14 @@ import { HorizontalGutter } from "coral-ui/components";
import AkismetConfigContainer from "./AkismetConfigContainer";
import PerspectiveConfigContainer from "./PerspectiveConfigContainer";
import PreModerationConfigContainer from "./PreModerationConfigContainer";
import RecentCommentHistoryConfigContainer from "./RecentCommentHistoryConfigContainer";
interface Props {
disabled: boolean;
settings: PropTypesOf<typeof AkismetConfigContainer>["settings"] &
PropTypesOf<typeof PerspectiveConfigContainer>["settings"] &
PropTypesOf<typeof PreModerationConfigContainer>["settings"];
PropTypesOf<typeof PreModerationConfigContainer>["settings"] &
PropTypesOf<typeof RecentCommentHistoryConfigContainer>["settings"];
onInitValues: (values: any) => void;
}
@@ -21,6 +23,11 @@ const ModerationConfig: FunctionComponent<Props> = ({
onInitValues,
}) => (
<HorizontalGutter size="double" data-testid="configure-moderationContainer">
<RecentCommentHistoryConfigContainer
disabled={disabled}
settings={settings}
onInitValues={onInitValues}
/>
<PreModerationConfigContainer
disabled={disabled}
settings={settings}
@@ -48,6 +48,7 @@ const enhanced = withFragmentContainer<Props>({
...AkismetConfigContainer_settings
...PerspectiveConfigContainer_settings
...PreModerationConfigContainer_settings
...RecentCommentHistoryConfigContainer_settings
}
`,
})(ModerationConfigContainer);
@@ -1,3 +1,3 @@
.thresholdTextField {
width: 60px;
width: calc(6 * var(--mini-unit));
}
@@ -0,0 +1,3 @@
.thresholdTextField {
width: calc(6 * var(--mini-unit));
}
@@ -0,0 +1,133 @@
import { Localized } from "fluent-react/compat";
import React, { FunctionComponent } from "react";
import { Field } from "react-final-form";
import { DURATION_UNIT, DurationField } from "coral-framework/components";
import {
formatPercentage,
parsePercentage,
ValidationMessage,
} from "coral-framework/lib/form";
import {
composeValidators,
required,
validatePercentage,
validateWholeNumberGreaterThan,
} from "coral-framework/lib/validation";
import {
FieldSet,
FormField,
HorizontalGutter,
InputDescription,
InputLabel,
TextField,
Typography,
} from "coral-ui/components";
import Header from "../../Header";
import OnOffField from "../../OnOffField";
import styles from "./RecentCommentHistoryConfig.css";
interface Props {
disabled: boolean;
}
const RecentCommentHistoryConfig: FunctionComponent<Props> = ({ disabled }) => {
return (
<HorizontalGutter size="oneAndAHalf" container={<FieldSet />}>
<Localized id="configure-moderation-recentCommentHistory-title">
<Header container="legend">Recent comment history</Header>
</Localized>
<FormField container={<FieldSet />}>
<Localized id="configure-moderation-recentCommentHistory-timeFrame">
<InputLabel container="legend">
Recent comment history timeframe
</InputLabel>
</Localized>
<Localized id="configure-moderation-recentCommentHistory-timeFrame-description">
<InputDescription>
Time period over which a commenter's rejection rate is calcualted
and submitted comments are counted.
</InputDescription>
</Localized>
<Field
name="recentCommentHistory.timeFrame"
validate={composeValidators(
required,
validateWholeNumberGreaterThan(0)
)}
>
{({ input, meta }) => (
<>
<DurationField
units={[DURATION_UNIT.DAYS]}
disabled={disabled}
{...input}
/>
<ValidationMessage meta={meta} />
</>
)}
</Field>
</FormField>
<FormField container={<FieldSet />}>
<Localized id="configure-moderation-recentCommentHistory-enabled">
<InputLabel container="legend">
Recent comment history filter
</InputLabel>
</Localized>
<Localized
id="configure-moderation-recentCommentHistory-enabled-description"
strong={<strong />}
>
<InputDescription>
Prevents repeat offenders from publishing comments without approval.
After a commenter's rejection rate rises above the defined threshold
below, their next submitted comments are{" "}
<strong>sent to Pending for moderator approval.</strong> The filter
is removed when their rejection rate falls below the threshold.
</InputDescription>
</Localized>
<OnOffField name="recentCommentHistory.enabled" disabled={disabled} />
</FormField>
<FormField>
<Localized id="configure-moderation-recentCommentHistory-triggerRejectionRate">
<InputLabel>Rejection rate threshold</InputLabel>
</Localized>
<Localized id="configure-moderation-recentCommentHistory-triggerRejectionRate-description">
<InputDescription>
Calculated by the number of rejected comments divided by the sum of
a commenters rejected and published comments, over the recent
comment history timeframe (does not include comments pending for
toxicity, spam or pre-moderation.)
</InputDescription>
</Localized>
<Field
name="recentCommentHistory.triggerRejectionRate"
parse={parsePercentage}
format={formatPercentage}
validate={validatePercentage(0, 1)}
>
{({ input, meta }) => (
<>
<TextField
className={styles.thresholdTextField}
disabled={disabled}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck={false}
adornment={<Typography variant="bodyCopy">%</Typography>}
textAlignCenter
{...input}
/>
<ValidationMessage meta={meta} />
</>
)}
</Field>
</FormField>
</HorizontalGutter>
);
};
export default RecentCommentHistoryConfig;
@@ -0,0 +1,39 @@
import React from "react";
import { graphql } from "react-relay";
import { RecentCommentHistoryConfigContainer_settings as SettingsData } from "coral-admin/__generated__/RecentCommentHistoryConfigContainer_settings.graphql";
import { withFragmentContainer } from "coral-framework/lib/relay";
import RecentCommentHistoryConfig from "./RecentCommentHistoryConfig";
interface Props {
settings: SettingsData;
onInitValues: (values: SettingsData) => void;
disabled: boolean;
}
class RecentCommentHistoryConfigContainer extends React.Component<Props> {
constructor(props: Props) {
super(props);
props.onInitValues(props.settings);
}
public render() {
const { disabled } = this.props;
return <RecentCommentHistoryConfig disabled={disabled} />;
}
}
const enhanced = withFragmentContainer<Props>({
settings: graphql`
fragment RecentCommentHistoryConfigContainer_settings on Settings {
recentCommentHistory {
enabled
timeFrame
triggerRejectionRate
}
}
`,
})(RecentCommentHistoryConfigContainer);
export default enhanced;
@@ -5,6 +5,11 @@ exports[`renders correctly 1`] = `
data-testid="configure-moderationContainer"
size="double"
>
<Relay(RecentCommentHistoryConfigContainer)
disabled={false}
onInitValues={[Function]}
settings={Object {}}
/>
<Relay(PreModerationConfigContainer)
disabled={false}
onInitValues={[Function]}
@@ -4,7 +4,7 @@ import { CSSTransition, TransitionGroup } from "react-transition-group";
import AutoLoadMore from "coral-admin/components/AutoLoadMore";
import ModerateCardContainer from "coral-admin/components/ModerateCard";
import UserHistoryDrawerContainer from "coral-admin/components/UserHistoryDrawer/UserHistoryDrawerContainer";
import UserHistoryDrawer from "coral-admin/components/UserHistoryDrawer";
import { Button, Flex, HorizontalGutter } from "coral-ui/components";
import { PropTypesOf } from "coral-ui/types";
@@ -101,7 +101,7 @@ const Queue: FunctionComponent<Props> = ({
</Flex>
)}
{comments.length === 0 && emptyElement}
<UserHistoryDrawerContainer
<UserHistoryDrawer
open={userDrawerVisible}
onClose={onHideUserDrawer}
userID={userDrawerId}
@@ -19,7 +19,7 @@ exports[`renders correctly with load more 1`] = `
onLoadMore={[Function]}
/>
</ForwardRef(forwardRef)>
<UserHistoryDrawerContainer
<UserHistoryDrawer
onClose={[Function]}
open={false}
userID=""
@@ -38,7 +38,7 @@ exports[`renders correctly without load more 1`] = `
enter={false}
exit={true}
/>
<UserHistoryDrawerContainer
<UserHistoryDrawer
onClose={[Function]}
open={false}
userID=""
@@ -3,7 +3,7 @@ import { createFarceRouter } from "found";
import { Resolver } from "found-relay";
import React, { FunctionComponent } from "react";
import UserHistoryDrawerContainer from "coral-admin/components/UserHistoryDrawer/UserHistoryDrawerContainer";
import UserHistoryDrawer from "coral-admin/components/UserHistoryDrawer";
import { CoralContextConsumer } from "coral-framework/lib/bootstrap/CoralContext";
import { makeRouteConfig, Route } from "found";
import { ConnectedRouter } from "found";
@@ -21,7 +21,7 @@ const harnessRouter = (userID: string): ConnectedRouter => {
routeConfig,
renderReady: ({ elements }) => (
<div data-testid="test-container">
<UserHistoryDrawerContainer
<UserHistoryDrawer
userID={userID}
open
onClose={() => {
@@ -116,6 +116,178 @@ exports[`renders configure moderation 1`] = `
className="Box-root HorizontalGutter-root HorizontalGutter-double"
data-testid="configure-moderationContainer"
>
<fieldset
className="FieldSet-root Box-root HorizontalGutter-root HorizontalGutter-oneAndAHalf"
>
<legend
className="Box-root Typography-root Typography-heading1 Typography-colorTextPrimary Header-root"
>
Recent comment history
</legend>
<fieldset
className="FieldSet-root Box-root HorizontalGutter-root FormField-root HorizontalGutter-half"
>
<legend
className="Box-root Typography-root Typography-inputLabel Typography-colorTextPrimary InputLabel-root"
>
Recent comment history timeframe
</legend>
<p
className="Box-root Typography-root Typography-detail Typography-colorTextSecondary"
>
Time period over which a commenter's rejection rate is calcualted
and submitted comments are counted.
</p>
<div
className="Box-root Flex-root Flex-flex Flex-itemGutter gutter"
>
<div
className="TextField-root DurationField-value"
>
<input
aria-label="value"
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
className="TextField-input TextField-colorRegular TextField-textAlignCenter"
disabled={false}
name="recentCommentHistory.timeFrame-value"
onChange={[Function]}
placeholder=""
spellCheck={false}
type="text"
value="7"
/>
<div
className="TextField-adornment"
>
<p
className="Box-root Typography-root Typography-bodyCopy Typography-colorTextPrimary"
>
Days
</p>
</div>
</div>
</div>
</fieldset>
<fieldset
className="FieldSet-root Box-root HorizontalGutter-root FormField-root HorizontalGutter-half"
>
<legend
className="Box-root Typography-root Typography-inputLabel Typography-colorTextPrimary InputLabel-root"
>
Recent comment history filter
</legend>
<p
className="Box-root Typography-root Typography-detail Typography-colorTextSecondary"
>
Prevents repeat offenders from publishing comments without approval.
After a commenter's rejection rate rises above the defined threshold
below, their next submitted comments are
<strong>
sent to Pending for
moderator approval.
</strong>
The filter is removed when their rejection rate
falls below the threshold.
</p>
<div>
<div
className="Box-root Flex-root RadioButton-root Flex-flex Flex-alignCenter"
>
<input
checked={false}
className="RadioButton-input"
disabled={false}
id="recentCommentHistory.enabled-true"
name="recentCommentHistory.enabled"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
type="radio"
value={true}
/>
<label
className="RadioButton-label"
htmlFor="recentCommentHistory.enabled-true"
>
<span>
On
</span>
</label>
</div>
<div
className="Box-root Flex-root RadioButton-root Flex-flex Flex-alignCenter"
>
<input
checked={true}
className="RadioButton-input"
disabled={false}
id="recentCommentHistory.enabled-false"
name="recentCommentHistory.enabled"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
type="radio"
value={false}
/>
<label
className="RadioButton-label"
htmlFor="recentCommentHistory.enabled-false"
>
<span>
Off
</span>
</label>
</div>
</div>
</fieldset>
<div
className="Box-root HorizontalGutter-root FormField-root HorizontalGutter-half"
>
<label
className="Box-root Typography-root Typography-inputLabel Typography-colorTextPrimary InputLabel-root"
>
Rejection rate threshold
</label>
<p
className="Box-root Typography-root Typography-detail Typography-colorTextSecondary"
>
Calculated by the number of rejected comments divided by the sum of
a commenters rejected and published comments, over the recent
comment history timeframe (does not include comments pending for
toxicity, spam or pre-moderation.)
</p>
<div
className="TextField-root RecentCommentHistoryConfig-thresholdTextField"
>
<input
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
className="TextField-input TextField-colorRegular TextField-textAlignCenter"
disabled={false}
name="recentCommentHistory.triggerRejectionRate"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
placeholder=""
spellCheck={false}
type="text"
value="30"
/>
<div
className="TextField-adornment"
>
<p
className="Box-root Typography-root Typography-bodyCopy Typography-colorTextPrimary"
>
%
</p>
</div>
</div>
</div>
</fieldset>
<fieldset
className="FieldSet-root Box-root HorizontalGutter-root HorizontalGutter-oneAndAHalf"
>
+22 -1
View File
@@ -61,6 +61,14 @@ export const settings = createFixture<GQLSettings>({
url: "https://test.com/",
contactEmail: "coral@test.com",
},
recentCommentHistory: {
enabled: false,
// 7 days in seconds.
timeFrame: 604800,
// Rejection rate defaulting to 30%, once exceeded, comments will be
// pre-moderated.
triggerRejectionRate: 0.3,
},
integrations: {
akismet: {
enabled: false,
@@ -292,6 +300,16 @@ export const baseUser = createFixture<GQLUser>({
},
});
const recentCommentHistory = {
statuses: {
APPROVED: 0,
REJECTED: 0,
NONE: 0,
PREMOD: 0,
SYSTEM_WITHHELD: 0,
},
};
export const users = {
admins: createFixtures<GQLUser>(
[
@@ -337,6 +355,7 @@ export const users = {
email: "isabelle@test.com",
role: GQLUSER_ROLE.COMMENTER,
ignoreable: true,
recentCommentHistory,
},
{
id: "user-commenter-1",
@@ -344,6 +363,7 @@ export const users = {
email: "ngoc@test.com",
role: GQLUSER_ROLE.COMMENTER,
ignoreable: true,
recentCommentHistory,
},
{
id: "user-commenter-2",
@@ -351,6 +371,7 @@ export const users = {
email: "max@test.com",
role: GQLUSER_ROLE.COMMENTER,
ignoreable: true,
recentCommentHistory,
},
],
baseUser
@@ -458,7 +479,7 @@ export const baseComment = createFixture<GQLComment>({
reasons: {
COMMENT_DETECTED_TOXIC: 0,
COMMENT_DETECTED_SPAM: 0,
COMMENT_DETECTED_TRUST: 0,
COMMENT_DETECTED_RECENT_HISTORY: 0,
COMMENT_DETECTED_LINKS: 0,
COMMENT_DETECTED_BANNED_WORD: 0,
COMMENT_DETECTED_SUSPECT_WORD: 0,
@@ -149,6 +149,7 @@ it("rejects single comment", async () => {
comment: {
id: comment.id,
status: GQLCOMMENT_STATUS.REJECTED,
author: comment.author,
statusHistory: {
edges: [
{
@@ -2,7 +2,13 @@ import { Localized } from "fluent-react/compat";
import React, { ChangeEvent, Component } from "react";
import { UNIT } from "coral-framework/lib/i18n";
import { Flex, Option, SelectField, TextField } from "coral-ui/components";
import {
Flex,
Option,
SelectField,
TextField,
Typography,
} from "coral-ui/components";
import styles from "./DurationField.css";
@@ -12,58 +18,12 @@ import styles from "./DurationField.css";
*/
export const DURATION_UNIT = UNIT;
type UnitElementCallback = (
currentValue: UNIT,
unitValue: string
) => React.ReactElement<any>;
// This is used to render the Option elements to inlcude in the select field.
const unitElementMap: Record<UNIT, UnitElementCallback> = {
[UNIT.SECONDS]: (currentValue, unitValue) => (
<Localized
id="framework-durationField-seconds"
$value={currentValue}
key={unitValue}
>
<Option value={unitValue}>Seconds</Option>
</Localized>
),
[UNIT.MINUTES]: (currentValue, unitValue) => (
<Localized
id="framework-durationField-minutes"
$value={currentValue}
key={unitValue}
>
<Option value={unitValue}>Minutes</Option>
</Localized>
),
[UNIT.HOURS]: (currentValue, unitValue) => (
<Localized
id="framework-durationField-hours"
$value={currentValue}
key={unitValue}
>
<Option value={unitValue}>Hours</Option>
</Localized>
),
[UNIT.DAYS]: (currentValue, unitValue) => (
<Localized
id="framework-durationField-days"
$value={currentValue}
key={unitValue}
>
<Option value={unitValue}>Days</Option>
</Localized>
),
[UNIT.WEEKS]: (currentValue, unitValue) => (
<Localized
id="framework-durationField-weeks"
$value={currentValue}
key={unitValue}
>
<Option value={unitValue}>Weeks</Option>
</Localized>
),
const DURATION_UNIT_MAP = {
[DURATION_UNIT.SECONDS]: "second",
[DURATION_UNIT.MINUTES]: "minute",
[DURATION_UNIT.HOURS]: "hour",
[DURATION_UNIT.DAYS]: "day",
[DURATION_UNIT.WEEKS]: "week",
};
interface Props {
@@ -86,7 +46,7 @@ interface State {
* Element callbacks to generate the rendered
* Option element for the select field
*/
elementCallbacks: ReadonlyArray<UnitElementCallback>;
elementCallbacks: ReadonlyArray<string>;
}
/**
@@ -117,7 +77,7 @@ function valueToState(value: string, units: ReadonlyArray<UNIT>, unit?: UNIT) {
unit,
value,
units,
elementCallbacks: units.map(k => unitElementMap[k]),
elementCallbacks: units.map(k => DURATION_UNIT_MAP[k]),
};
}
@@ -177,6 +137,25 @@ class DurationField extends Component<Props, State> {
public render() {
const { disabled, name } = this.props;
if (!this.state.elementCallbacks) {
return null;
}
let adornment: React.ReactNode = null;
if (this.state.elementCallbacks.length === 1) {
const unit = this.state.elementCallbacks[0];
adornment = (
<Localized
id="framework-durationField-unit"
$unit={unit}
$value={parseInt(this.state.value, 10)}
>
<Typography variant="bodyCopy">{unit}</Typography>
</Localized>
);
}
return (
<Flex itemGutter>
<TextField
@@ -189,23 +168,36 @@ class DurationField extends Component<Props, State> {
autoCorrect="off"
autoCapitalize="off"
spellCheck={false}
adornment={adornment}
textAlignCenter
aria-label="value"
/>
<SelectField
name={`${name}-unit`}
onChange={this.handleUnitChange}
disabled={disabled}
aria-label="unit"
classes={{
select: styles.unit,
}}
value={(this.state.unit || this.state.units[0]).toString()}
>
{this.state.elementCallbacks!.map((cb, i) =>
cb(parseInt(this.state.value, 10), this.state.units[i].toString())
)}
</SelectField>
{!adornment && (
<SelectField
name={`${name}-unit`}
onChange={this.handleUnitChange}
disabled={disabled}
aria-label="unit"
classes={{
select: styles.unit,
}}
value={(this.state.unit || this.state.units[0]).toString()}
>
{this.state.elementCallbacks.map((unit, i) => {
const value = this.state.units[i];
return (
<Localized
id="framework-durationField-unit"
$unit={unit}
$value={parseInt(this.state.value, 10)}
key={i}
>
<Option value={value.toString()}>{unit}</Option>
</Localized>
);
})}
</SelectField>
)}
</Flex>
);
}
@@ -5,6 +5,7 @@ exports[`accepts invalid input 1`] = `
itemGutter={true}
>
<withPropsOnChange(TextField)
adornment={null}
aria-label="value"
autoCapitalize="off"
autoComplete="off"
@@ -30,33 +31,36 @@ exports[`accepts invalid input 1`] = `
value="1"
>
<Localized
$unit="second"
$value={NaN}
id="framework-durationField-seconds"
id="framework-durationField-unit"
>
<Option
value="1"
>
Seconds
second
</Option>
</Localized>
<Localized
$unit="minute"
$value={NaN}
id="framework-durationField-minutes"
id="framework-durationField-unit"
>
<Option
value="60"
>
Minutes
minute
</Option>
</Localized>
<Localized
$unit="hour"
$value={NaN}
id="framework-durationField-hours"
id="framework-durationField-unit"
>
<Option
value="3600"
>
Hours
hour
</Option>
</Localized>
</withPropsOnChange(WithKeyboardFocus)>
@@ -68,6 +72,7 @@ exports[`renders correctly with default units 1`] = `
itemGutter={true}
>
<withPropsOnChange(TextField)
adornment={null}
aria-label="value"
autoCapitalize="off"
autoComplete="off"
@@ -93,33 +98,36 @@ exports[`renders correctly with default units 1`] = `
value="3600"
>
<Localized
$unit="hour"
$value={NaN}
id="framework-durationField-hours"
id="framework-durationField-unit"
>
<Option
value="3600"
>
Hours
hour
</Option>
</Localized>
<Localized
$unit="day"
$value={NaN}
id="framework-durationField-days"
id="framework-durationField-unit"
>
<Option
value="86400"
>
Days
day
</Option>
</Localized>
<Localized
$unit="week"
$value={NaN}
id="framework-durationField-weeks"
id="framework-durationField-unit"
>
<Option
value="604800"
>
Weeks
week
</Option>
</Localized>
</withPropsOnChange(WithKeyboardFocus)>
@@ -131,6 +139,7 @@ exports[`renders correctly with specified units 1`] = `
itemGutter={true}
>
<withPropsOnChange(TextField)
adornment={null}
aria-label="value"
autoCapitalize="off"
autoComplete="off"
@@ -156,23 +165,25 @@ exports[`renders correctly with specified units 1`] = `
value="1"
>
<Localized
$unit="second"
$value={NaN}
id="framework-durationField-seconds"
id="framework-durationField-unit"
>
<Option
value="1"
>
Seconds
second
</Option>
</Localized>
<Localized
$unit="hour"
$value={NaN}
id="framework-durationField-hours"
id="framework-durationField-unit"
>
<Option
value="3600"
>
Hours
hour
</Option>
</Localized>
</withPropsOnChange(WithKeyboardFocus)>
@@ -184,6 +195,7 @@ exports[`use best matching unit 1`] = `
itemGutter={true}
>
<withPropsOnChange(TextField)
adornment={null}
aria-label="value"
autoCapitalize="off"
autoComplete="off"
@@ -209,33 +221,36 @@ exports[`use best matching unit 1`] = `
value="3600"
>
<Localized
$unit="second"
$value={1}
id="framework-durationField-seconds"
id="framework-durationField-unit"
>
<Option
value="1"
>
Seconds
second
</Option>
</Localized>
<Localized
$unit="minute"
$value={1}
id="framework-durationField-minutes"
id="framework-durationField-unit"
>
<Option
value="60"
>
Minutes
minute
</Option>
</Localized>
<Localized
$unit="hour"
$value={1}
id="framework-durationField-hours"
id="framework-durationField-unit"
>
<Option
value="3600"
>
Hours
hour
</Option>
</Localized>
</withPropsOnChange(WithKeyboardFocus)>
@@ -247,6 +262,7 @@ exports[`use initial unit if 0 1`] = `
itemGutter={true}
>
<withPropsOnChange(TextField)
adornment={null}
aria-label="value"
autoCapitalize="off"
autoComplete="off"
@@ -272,33 +288,36 @@ exports[`use initial unit if 0 1`] = `
value="1"
>
<Localized
$unit="second"
$value={0}
id="framework-durationField-seconds"
id="framework-durationField-unit"
>
<Option
value="1"
>
Seconds
second
</Option>
</Localized>
<Localized
$unit="minute"
$value={0}
id="framework-durationField-minutes"
id="framework-durationField-unit"
>
<Option
value="60"
>
Minutes
minute
</Option>
</Localized>
<Localized
$unit="hour"
$value={0}
id="framework-durationField-hours"
id="framework-durationField-unit"
>
<Option
value="3600"
>
Hours
hour
</Option>
</Localized>
</withPropsOnChange(WithKeyboardFocus)>
@@ -74,7 +74,7 @@ export function denormalizeStory(story: Fixture<GQLStory>) {
comments: { edges: commentNodes, pageInfo: commentsPageInfo },
commentCounts: {
...story.commentCounts,
totalVisible: commentNodes.length,
totalPublished: commentNodes.length,
tags: {
...(story.commentCounts && story.commentCounts.tags),
FEATURED: featuredCommentsCount,
@@ -11,6 +11,7 @@ import { CommentContainer_comment as CommentData } from "coral-stream/__generate
import { CommentContainer_settings as SettingsData } from "coral-stream/__generated__/CommentContainer_settings.graphql";
import { CommentContainer_story as StoryData } from "coral-stream/__generated__/CommentContainer_story.graphql";
import { CommentContainer_viewer as ViewerData } from "coral-stream/__generated__/CommentContainer_viewer.graphql";
import CLASSES from "coral-stream/classes";
import {
SetCommentIDMutation,
ShowAuthPopupMutation,
@@ -19,8 +20,7 @@ import {
} from "coral-stream/mutations";
import { Button, Flex, HorizontalGutter, Tag } from "coral-ui/components";
import CLASSES from "coral-stream/classes";
import { isCommentVisible } from "../helpers";
import { isPublished } from "../helpers";
import ButtonsBar from "./ButtonsBar";
import EditCommentFormContainer from "./EditCommentForm";
import IndentedComment from "./IndentedComment";
@@ -205,15 +205,15 @@ export class CommentContainer extends Component<Props, State> {
</div>
);
}
// Comment is not visible after viewer rejected it.
// Comment is not published after viewer rejected it.
if (
comment.lastViewerAction === "REJECT" &&
comment.status === "REJECTED"
) {
return <RejectedTombstoneContainer comment={comment} />;
}
// Comment is not visible after edit, so don't render it anymore.
if (comment.lastViewerAction === "EDIT" && !isCommentVisible(comment)) {
// Comment is not published after edit, so don't render it anymore.
if (comment.lastViewerAction === "EDIT" && !isPublished(comment.status)) {
return null;
}
return (
@@ -22,7 +22,7 @@ import { CreateCommentReplyMutation as MutationTypes } from "coral-stream/__gene
import { pick } from "lodash";
import {
incrementStoryCommentCounts,
isVisible,
isPublished,
prependCommentEdgeToProfile,
} from "../../helpers";
@@ -40,8 +40,8 @@ function sharedUpdater(
.getLinkedRecord("edge")!;
const status = commentEdge.getLinkedRecord("node")!.getValue("status");
// If comment is not visible, we don't need to add it.
if (!isVisible(status)) {
// If comment is not published, we don't need to add it.
if (!isPublished(status)) {
return;
}
@@ -17,7 +17,7 @@ import { ReplyListContainer1_viewer as ViewerData } from "coral-stream/__generat
import { ReplyListContainer1PaginationQueryVariables } from "coral-stream/__generated__/ReplyListContainer1PaginationQuery.graphql";
import { ReplyListContainer5_comment as Comment5Data } from "coral-stream/__generated__/ReplyListContainer5_comment.graphql";
import { isCommentVisible } from "../helpers";
import { isPublished } from "../helpers";
import CommentReplyCreatedSubscription from "./CommentReplyCreatedSubscription";
import LocalReplyListContainer from "./LocalReplyListContainer";
import ReplyList from "./ReplyList";
@@ -105,7 +105,7 @@ export const ReplyListContainer: React.FunctionComponent<Props> = props => {
}
const comments =
// Comment is not visible after a viewer action, so don't render it anymore.
props.comment.lastViewerAction && !isCommentVisible(props.comment)
props.comment.lastViewerAction && !isPublished(props.comment.status)
? []
: props.comment.replies.edges.map(edge => ({
...edge.node,
@@ -1,17 +1,7 @@
import cn from "classnames";
import { Localized } from "fluent-react/compat";
import React, { FunctionComponent } from "react";
import {
BaseButton,
Box,
ClickOutside,
Icon,
Popover,
Typography,
} from "coral-ui/components";
import { Localized } from "fluent-react/compat";
import styles from "./FeaturedCommentTooltip.css";
import { Tooltip, TooltipButton } from "coral-ui/components";
interface Props {
className?: string;
@@ -20,60 +10,33 @@ interface Props {
export const FeaturedCommentTooltip: FunctionComponent<Props> = props => {
return (
<Popover
<Tooltip
id="comments-featuredCommentPopover"
className={cn(styles.root, props.className)}
body={({ toggleVisibility }) => (
<ClickOutside onClickOutside={toggleVisibility}>
<Box
p={2}
className={styles.tooltip}
onClick={evt => {
// Don't propagate click events when clicking inside of popover to
// avoid accidently activating the featured comments tab.
evt.stopPropagation();
}}
>
<Localized id="comments-featuredCommentTooltip-how">
<Typography
color="textLight"
variant="bodyCopyBold"
mb={2}
className={styles.title}
>
How is a comment featured?
</Typography>
</Localized>
<Localized id="comments-featuredCommentTooltip-handSelectedComments">
<Typography color="textLight" variant="detail">
Comments are hand selected by our team as worth reading.
</Typography>
</Localized>
</Box>
</ClickOutside>
)}
placement={"bottom"}
dark
>
{({ toggleVisibility, ref }) => (
className={props.className}
title={
<Localized id="comments-featuredCommentTooltip-how">
<span>How is a comment featured?</span>
</Localized>
}
body={
<Localized id="comments-featuredCommentTooltip-handSelectedComments">
<span>Comments are hand selected by our team as worth reading.</span>
</Localized>
}
button={({ toggleVisibility, ref }) => (
<Localized
id="comments-featuredCommentTooltip-toggleButton"
attrs={{ "aria-label": true }}
>
<BaseButton
className={styles.button}
onClick={evt => {
evt.stopPropagation();
toggleVisibility();
}}
<TooltipButton
active={props.active}
aria-label="Toggle featured comments tooltip"
toggleVisibility={toggleVisibility}
ref={ref}
>
<Icon color={props.active ? "primary" : "inherit"}>info</Icon>
</BaseButton>
/>
</Localized>
)}
</Popover>
/>
);
};
@@ -21,7 +21,7 @@ import { CreateCommentMutation as MutationTypes } from "coral-stream/__generated
import {
incrementStoryCommentCounts,
isVisible,
isPublished,
prependCommentEdgeToProfile,
} from "../../helpers";
@@ -38,7 +38,7 @@ function sharedUpdater(
const status = commentEdge.getLinkedRecord("node")!.getValue("status");
// If comment is not visible, we don't need to add it.
if (!isVisible(status)) {
if (!isPublished(status)) {
return;
}
@@ -81,7 +81,7 @@ export const StreamContainer: FunctionComponent<Props> = props => {
props.viewer.status.current.includes(GQLUSER_STATUS.SUSPENDED)
);
const allCommentsCount = props.story.commentCounts.totalVisible;
const allCommentsCount = props.story.commentCounts.totalPublished;
const featuredCommentsCount = props.story.commentCounts.tags.FEATURED;
useEffect(() => {
@@ -191,7 +191,7 @@ const enhanced = withFragmentContainer<Props>({
...CreateCommentReplyMutation_story
...CreateCommentMutation_story
commentCounts {
totalVisible
totalPublished
tags {
FEATURED
}
@@ -10,8 +10,8 @@ export default function incrementStoryCommentCounts(
const record = story.getLinkedRecord("commentCounts");
if (record) {
// TODO: when we have moderation, we'll need to be careful here.
const currentCount = record.getValue("totalVisible");
record.setValue(currentCount + 1, "totalVisible");
const currentCount = record.getValue("totalPublished");
record.setValue(currentCount + 1, "totalPublished");
}
}
}
@@ -8,13 +8,12 @@ export {
} from "./shouldTriggerSettingsRefresh";
export { default as getHTMLText } from "./getHTMLText";
export { default as getSubmitStatus, SubmitStatus } from "./getSubmitStatus";
export { default as isCommentVisible } from "./isCommentVisible";
export {
default as incrementStoryCommentCounts,
} from "./incrementStoryCommentCounts";
export { default as isInReview } from "./isInReview";
export { default as isRejected } from "./isRejected";
export { default as isVisible } from "./isVisible";
export { default as isPublished } from "./isPublished";
export {
default as prependCommentEdgeToProfile,
} from "./prependCommentEdgeToProfile";
@@ -1,9 +0,0 @@
import { COMMENT_STATUS } from "coral-stream/__generated__/CreateCommentMutation.graphql";
const VisibleStatus = ["APPROVED", "NONE"];
export default function isCommentVisible(comment: {
status: COMMENT_STATUS;
}): boolean {
return VisibleStatus.includes(comment.status);
}
@@ -0,0 +1,7 @@
import { GQLCOMMENT_STATUS } from "coral-framework/schema";
const PUBLISHED_STATUSES = [GQLCOMMENT_STATUS.NONE, GQLCOMMENT_STATUS.APPROVED];
export default function isPublished(status: any) {
return PUBLISHED_STATUSES.includes(status);
}
@@ -1,6 +0,0 @@
// TODO: use generated schema types.
const visibleStatuses = ["NONE", "APPROVED"];
export default function isVisible(status: any) {
return visibleStatuses.includes(status);
}
@@ -117,7 +117,7 @@ it("edit a comment", async () => {
expect(within(comment).toJSON()).toMatchSnapshot("server response");
});
it("edit a comment and handle non-visible comment state", async () => {
it("edit a comment and handle non-published comment state", async () => {
const testRenderer = createTestRenderer({}, { status: "SYSTEM_WITHHELD" });
const comment = await waitForElement(() =>
@@ -118,7 +118,7 @@ it("post a comment", async () => {
);
});
const postACommentAndHandleNonVisibleComment = async (
const postACommentAndHandleNonPublishedComment = async (
dismiss: (form: ReactTestInstance, rte: ReactTestInstance) => void
) => {
const { rte, form } = await createTestRenderer({
@@ -161,14 +161,14 @@ const postACommentAndHandleNonVisibleComment = async (
};
it("post a comment and handle non-visible comment state (dismiss by click)", async () =>
await postACommentAndHandleNonVisibleComment((form, rte) => {
await postACommentAndHandleNonPublishedComment((form, rte) => {
within(form)
.getByText("Dismiss")
.props.onClick();
}));
it("post a comment and handle non-visible comment state (dismiss by typing)", async () =>
await postACommentAndHandleNonVisibleComment((form, rte) => {
await postACommentAndHandleNonPublishedComment((form, rte) => {
rte.props.onChange({ html: "Typing..." });
}));
+1 -1
View File
@@ -375,7 +375,7 @@ export const baseStory = createFixture<GQLStory>({
},
},
commentCounts: {
totalVisible: 0,
totalPublished: 0,
tags: {
FEATURED: 0,
},
+3 -3
View File
@@ -91,7 +91,7 @@ export function createComment(author?: GQLUser) {
reasons: {
COMMENT_DETECTED_TOXIC: 0,
COMMENT_DETECTED_SPAM: 0,
COMMENT_DETECTED_TRUST: 0,
COMMENT_DETECTED_RECENT_HISTORY: 0,
COMMENT_DETECTED_LINKS: 0,
COMMENT_DETECTED_BANNED_WORD: 0,
COMMENT_DETECTED_SUSPECT_WORD: 0,
@@ -139,7 +139,7 @@ export function createComment(author?: GQLUser) {
COMMENT_REPORTED_OTHER: 0,
COMMENT_DETECTED_TOXIC: 0,
COMMENT_DETECTED_SPAM: 0,
COMMENT_DETECTED_TRUST: 0,
COMMENT_DETECTED_RECENT_HISTORY: 0,
COMMENT_DETECTED_LINKS: 0,
COMMENT_DETECTED_BANNED_WORD: 0,
COMMENT_DETECTED_SUSPECT_WORD: 0,
@@ -210,7 +210,7 @@ export function createStory() {
},
isClosed: false,
commentCounts: {
totalVisible: 0,
totalPublished: 0,
tags: {
FEATURED: 0,
},
@@ -11,9 +11,3 @@
.title {
line-height: 1;
}
.button {
line-height: 0;
color: var(--palette-grey-dark);
padding: 6px;
}
@@ -0,0 +1,62 @@
import cn from "classnames";
import React, { FunctionComponent } from "react";
import { Box, ClickOutside, Popover, Typography } from "coral-ui/components";
import { PropTypesOf } from "coral-ui/types";
import styles from "./Tooltip.css";
interface Props {
id: string;
className?: string;
title: React.ReactNode;
body: React.ReactNode;
button: PropTypesOf<typeof Popover>["children"];
}
export const Tooltip: FunctionComponent<Props> = ({
id,
className,
title,
body,
button,
}) => {
return (
<Popover
id={id}
className={cn(styles.root, className)}
body={({ toggleVisibility }) => (
<ClickOutside onClickOutside={toggleVisibility}>
<Box
p={2}
className={styles.tooltip}
onClick={evt => {
// Don't propagate click events when clicking inside of popover to
// avoid accidentally activating other components.
evt.stopPropagation();
}}
>
<Typography
color="textLight"
variant="bodyCopyBold"
mb={2}
className={styles.title}
>
{title}
</Typography>
<Typography color="textLight" variant="detail">
{body}
</Typography>
</Box>
</ClickOutside>
)}
placement={"bottom"}
dark
>
{props => button(props)}
</Popover>
);
};
export default Tooltip;
@@ -0,0 +1,5 @@
.button {
line-height: 0;
color: var(--palette-grey-dark);
padding: 6px;
}
@@ -0,0 +1,39 @@
import cn from "classnames";
import React, { ButtonHTMLAttributes, FunctionComponent, Ref } from "react";
import { BaseButton, Icon } from "coral-ui/components";
import { withForwardRef } from "coral-ui/hocs";
import { PropTypesOf } from "coral-ui/types";
import styles from "./TooltipButton.css";
interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
active?: boolean;
className?: string;
toggleVisibility: () => void;
/** Internal: Forwarded Ref */
forwardRef?: Ref<HTMLButtonElement>;
}
const TooltipButton: FunctionComponent<Props> = ({
active,
className,
toggleVisibility,
forwardRef,
}) => (
<BaseButton
className={cn(styles.button, className)}
onClick={evt => {
evt.stopPropagation();
toggleVisibility();
}}
ref={forwardRef}
>
<Icon color={active ? "primary" : "inherit"}>info</Icon>
</BaseButton>
);
const enhanced = withForwardRef(TooltipButton);
export type TooltipButtonProps = PropTypesOf<typeof enhanced>;
export default enhanced;
@@ -0,0 +1,2 @@
export { default, default as Tooltip } from "./Tooltip";
export { default as TooltipButton } from "./TooltipButton";
+1
View File
@@ -31,6 +31,7 @@ export { default as CheckBox } from "./CheckBox";
export { default as RadioButton } from "./RadioButton";
export { default as Delay } from "./Delay";
export { default as Box } from "./Box";
export { default as Tooltip, TooltipButton } from "./Tooltip";
export {
AppBar,
Begin as AppBarBegin,
@@ -2,7 +2,7 @@ import { GraphQLScalarType } from "graphql";
import { Kind } from "graphql/language";
import { DateTime } from "luxon";
import { Cursor } from "coral-server/models/helpers/connection";
import { Cursor } from "coral-server/models/helpers";
function parseIntegerCursor(value: string): number | null {
try {
+12 -3
View File
@@ -5,6 +5,7 @@ import {
createPublisher,
Publisher,
} from "coral-server/graph/tenant/subscriptions/publisher";
import logger from "coral-server/logger";
import { Tenant } from "coral-server/models/tenant";
import { User } from "coral-server/models/user";
import { MailerQueue } from "coral-server/queue/tasks/mailer";
@@ -37,10 +38,18 @@ export default class TenantContext extends CommonContext {
public readonly loaders: ReturnType<typeof loaders>;
public readonly mutators: ReturnType<typeof mutators>;
constructor(options: TenantContextOptions) {
super({ ...options, lang: options.tenant.locale });
constructor({
tenant,
logger: log = logger,
...options
}: TenantContextOptions) {
super({
...options,
lang: tenant.locale,
logger: logger.child({ tenantID: tenant.id }),
});
this.tenant = options.tenant;
this.tenant = tenant;
this.tenantCache = options.tenantCache;
this.scraperQueue = options.scraperQueue;
this.mailerQueue = options.mailerQueue;
@@ -1,5 +1,6 @@
import DataLoader from "dataloader";
import { isNil, omitBy } from "lodash";
import { DateTime } from "luxon";
import Context from "coral-server/graph/tenant/context";
import {
@@ -26,11 +27,12 @@ import {
retrieveCommentStoryConnection,
retrieveCommentUserConnection,
retrieveManyComments,
retrieveManyRecentStatusCounts,
retrieveRejectedCommentUserConnection,
retrieveStoryCommentTagCounts,
} from "coral-server/models/comment";
import { hasVisibleStatus } from "coral-server/models/comment/helpers";
import { Connection } from "coral-server/models/helpers/connection";
import { hasPublishedStatus } from "coral-server/models/comment/helpers";
import { Connection } from "coral-server/models/helpers";
import { retrieveSharedModerationQueueQueuesCounts } from "coral-server/models/story/counts/shared";
import { User } from "coral-server/models/user";
@@ -84,7 +86,7 @@ const mapVisibleComment = (user?: Pick<User, "role">) => {
return null;
}
if (hasVisibleStatus(comment) || isPrivilegedUser) {
if (hasPublishedStatus(comment) || isPrivilegedUser) {
return comment;
}
@@ -268,4 +270,14 @@ export default (ctx: Context) => ({
tagCounts: new DataLoader((storyIDs: string[]) =>
retrieveStoryCommentTagCounts(ctx.mongo, ctx.tenant.id, storyIDs)
),
authorStatusCounts: new DataLoader((authorIDs: string[]) =>
retrieveManyRecentStatusCounts(
ctx.mongo,
ctx.tenant.id,
DateTime.fromJSDate(ctx.now)
.plus({ seconds: -ctx.tenant.recentCommentHistory.timeFrame })
.toJSDate(),
authorIDs
)
),
});
@@ -5,7 +5,7 @@ import {
GQLSTORY_STATUS,
QueryToStoriesArgs,
} from "coral-server/graph/tenant/schema/__generated__/types";
import { Connection } from "coral-server/models/helpers/connection";
import { Connection } from "coral-server/models/helpers";
import {
retrieveManyStories,
retrieveStoryConnection,
@@ -6,7 +6,7 @@ import {
GQLUSER_STATUS,
QueryToUsersArgs,
} from "coral-server/graph/tenant/schema/__generated__/types";
import { Connection } from "coral-server/models/helpers/connection";
import { Connection } from "coral-server/models/helpers";
import {
retrieveManyUsers,
retrieveUserConnection,
@@ -1,5 +1,6 @@
import { GraphQLResolveInfo } from "graphql";
import { StoryNotFoundError } from "coral-server/errors";
import { getRequestedFields } from "coral-server/graph/tenant/resolvers/util";
import {
GQLComment,
@@ -10,12 +11,13 @@ import {
decodeActionCounts,
} from "coral-server/models/action/comment";
import * as comment from "coral-server/models/comment";
import { getLatestRevision } from "coral-server/models/comment";
import { createConnection } from "coral-server/models/helpers/connection";
import {
getLatestRevision,
hasAncestors,
} from "coral-server/models/comment/helpers";
import { createConnection } from "coral-server/models/helpers";
import { getCommentEditableUntilDate } from "coral-server/services/comments";
import { StoryNotFoundError } from "coral-server/errors";
import { hasAncestors } from "coral-server/models/comment/helpers";
import TenantContext from "../context";
import { getURLWithCommentID } from "./util";
@@ -1,12 +1,11 @@
import { GQLCommentCountsTypeResolver } from "coral-server/graph/tenant/schema/__generated__/types";
import { VISIBLE_STATUSES } from "coral-server/models/comment/constants";
import { PUBLISHED_STATUSES } from "coral-server/models/comment/constants";
import { Story } from "coral-server/models/story";
export type CommentCountsInput = Pick<Story, "commentCounts" | "id">;
export const CommentCounts: GQLCommentCountsTypeResolver<CommentCountsInput> = {
totalVisible: ({ commentCounts }) =>
VISIBLE_STATUSES.reduce(
totalPublished: ({ commentCounts }) =>
PUBLISHED_STATUSES.reduce(
(total, status) => total + commentCounts.status[status],
0
),
+16 -1
View File
@@ -1,7 +1,22 @@
import { GQLFlagTypeResolver } from "coral-server/graph/tenant/schema/__generated__/types";
import {
GQLCOMMENT_FLAG_REASON,
GQLFlagTypeResolver,
} from "coral-server/graph/tenant/schema/__generated__/types";
import * as actions from "coral-server/models/action/comment";
export const Flag: GQLFlagTypeResolver<actions.CommentAction> = {
reason: ({ id, reason }, args, ctx) => {
if (reason && reason in GQLCOMMENT_FLAG_REASON) {
return reason;
}
ctx.logger.warn(
{ actionID: id, flagReason: reason },
"found an invalid reason"
);
return null;
},
flagger: ({ userID }, args, ctx) => {
if (userID) {
return ctx.loaders.Users.user.load(userID);
@@ -5,7 +5,7 @@ import {
RejectCommentPayloadToModerationQueuesResolver,
} from "coral-server/graph/tenant/schema/__generated__/types";
import { CommentConnectionInput } from "coral-server/models/comment";
import { FilterQuery } from "coral-server/models/helpers/query";
import { FilterQuery } from "coral-server/models/helpers";
import {
CommentModerationCountsPerQueue,
Story,
@@ -0,0 +1,12 @@
import { GQLRecentCommentHistoryTypeResolver } from "coral-server/graph/tenant/schema/__generated__/types";
export interface RecentCommentHistoryInput {
userID: string;
}
export const RecentCommentHistory: Required<
GQLRecentCommentHistoryTypeResolver<RecentCommentHistoryInput>
> = {
statuses: ({ userID }, args, ctx) =>
ctx.loaders.Comments.authorStatusCounts.load(userID),
};
@@ -8,6 +8,7 @@ import {
import * as user from "coral-server/models/user";
import { roleIsStaff } from "coral-server/models/user/helpers";
import { RecentCommentHistoryInput } from "./RecentCommentHistory";
import { UserStatusInput } from "./UserStatus";
import { getRequestedFields } from "./util";
@@ -47,4 +48,5 @@ export const User: GQLUserTypeResolver<user.User> = {
ignoredUsers: ({ ignoredUsers }, input, ctx, info) =>
maybeLoadOnlyIgnoredUserID(ctx, info, ignoredUsers),
ignoreable: ({ role }) => !roleIsStaff(role),
recentCommentHistory: ({ id }): RecentCommentHistoryInput => ({ userID: id }),
};
@@ -29,6 +29,7 @@ import { Mutation } from "./Mutation";
import { OIDCAuthIntegration } from "./OIDCAuthIntegration";
import { Profile } from "./Profile";
import { Query } from "./Query";
import { RecentCommentHistory } from "./RecentCommentHistory";
import { RejectCommentPayload } from "./RejectCommentPayload";
import { Story } from "./Story";
import { StorySettings } from "./StorySettings";
@@ -68,6 +69,7 @@ const Resolvers: GQLResolver = {
OIDCAuthIntegration,
Profile,
Query,
RecentCommentHistory,
RejectCommentPayload,
Story,
StorySettings,
@@ -107,12 +107,6 @@ enum COMMENT_FLAG_DETECTED_REASON {
"""
COMMENT_DETECTED_SPAM
"""
COMMENT_DETECTED_TRUST is used when the Comment being left was done by a User
that has a low karma/trust score.
"""
COMMENT_DETECTED_TRUST
"""
COMMENT_DETECTED_LINKS is used when the Comment was detected as containing
links.
@@ -130,6 +124,12 @@ enum COMMENT_FLAG_DETECTED_REASON {
containing a suspect word.
"""
COMMENT_DETECTED_SUSPECT_WORD
"""
COMMENT_DETECTED_RECENT_HISTORY is used when a Comment author has exhibited a
recent history of rejected comments.
"""
COMMENT_DETECTED_RECENT_HISTORY
}
"""
@@ -142,10 +142,10 @@ enum COMMENT_FLAG_REASON {
COMMENT_REPORTED_OTHER
COMMENT_DETECTED_TOXIC
COMMENT_DETECTED_SPAM
COMMENT_DETECTED_TRUST
COMMENT_DETECTED_LINKS
COMMENT_DETECTED_BANNED_WORD
COMMENT_DETECTED_SUSPECT_WORD
COMMENT_DETECTED_RECENT_HISTORY
}
"""
@@ -176,10 +176,10 @@ type FlagReasonActionCounts {
COMMENT_REPORTED_OTHER: Int!
COMMENT_DETECTED_TOXIC: Int!
COMMENT_DETECTED_SPAM: Int!
COMMENT_DETECTED_TRUST: Int!
COMMENT_DETECTED_LINKS: Int!
COMMENT_DETECTED_BANNED_WORD: Int!
COMMENT_DETECTED_SUSPECT_WORD: Int!
COMMENT_DETECTED_RECENT_HISTORY: Int!
}
type Flag {
@@ -190,9 +190,11 @@ type Flag {
flagger: User
"""
reason is the selected reason why the Flag is being created.
reason is the selected reason why the Flag is being created. If the reason is
not defined, or existed from a previous version of Coral, it will return null
to avoid errors.
"""
reason: COMMENT_FLAG_REASON!
reason: COMMENT_FLAG_REASON
"""
additionalDetails stores information from the User as to why the Flag was
@@ -795,46 +797,45 @@ type ExternalIntegrations {
}
################################################################################
## Karma
## RecentCommentHistory
################################################################################
"""
KarmaThreshold defines the bounds for which a User will become unreliable or
reliable based on their karma score. If the score is equal or less than the
unreliable value, they are unreliable. If the score is equal or more than the
reliable value, they are reliable. If they are neither reliable or unreliable
then they are neutral.
RecentCommentHistoryConfiguration controls the beheviour around when comments
from Users should be marked for pre-moderation automatically once they have
reached the trigger rate for rejected comments.
"""
type KarmaThreshold {
reliable: Int!
unreliable: Int!
}
type KarmaThresholds {
type RecentCommentHistoryConfiguration {
"""
flag represents karma settings in relation to how well a User's flagging
ability aligns with the moderation decisions made by moderators.
"""
flag: KarmaThreshold!
"""
comment represents the karma setting in relation to how well a User's comments are moderated.
"""
comment: KarmaThreshold!
}
type Karma {
"""
When true, checks will be completed to ensure that the Karma checks are
completed.
enabled when true will pre-moderate users new comments once they have reached
the trigger rejection rate.
"""
enabled: Boolean!
"""
karmaThresholds contains the currently set thresholds for triggering Trust
behavior.
timeFrame specifies the number of seconds that comments from a given User will
be taken into account when computing their rejection rate.
"""
thresholds: KarmaThresholds!
timeFrame: Int!
"""
triggerRejectionRate specifies the percentage of comments that a given User
may have before their comments will then be placed into pre-moderation until
the rejection rate decreases.
"""
triggerRejectionRate: Float!
}
"""
RecentCommentHistory returns data associated with a User's recent commenting
history within the specified timeFrame configured.
"""
type RecentCommentHistory {
"""
statuses stores the counts of all the statuses against Comments by a User
within the timeFrame configured.
"""
statuses: CommentStatusCounts!
}
################################################################################
@@ -1176,10 +1177,11 @@ type Settings {
integrations: ExternalIntegrations! @auth(roles: [ADMIN, MODERATOR])
"""
karma is the set of settings related to how user Trust and Karma are
handled.
recentCommentHistory is the set of settings related to how automatic
pre-moderation is controlled.
"""
karma: Karma! @auth(roles: [ADMIN, MODERATOR])
recentCommentHistory: RecentCommentHistoryConfiguration!
@auth(roles: [ADMIN, MODERATOR])
"""
reaction specifies the configuration for reactions.
@@ -1558,6 +1560,11 @@ type User {
rejectedComments(first: Int = 10, after: Cursor): CommentsConnection!
@auth(roles: [ADMIN, MODERATOR])
"""
recentCommentHistory returns recent commenting history by the User.
"""
recentCommentHistory: RecentCommentHistory! @auth(roles: [ADMIN, MODERATOR])
"""
commentModerationActionHistory returns a CommentModerationActionConnection
that this User has created.
@@ -2057,9 +2064,9 @@ type CommentStatusCounts {
type CommentCounts {
"""
totalVisible will return the count of all visible Comments.
totalPublished will return the count of all published Comments.
"""
totalVisible: Int!
totalPublished: Int!
"""
tags stores the counts of all the Tags against Comment's on this Story.
@@ -2854,42 +2861,29 @@ input SettingsExternalIntegrationsInput {
}
"""
KarmaThreshold defines the bounds for which a User will become unreliable or
reliable based on their karma score. If the score is equal or less than the
unreliable value, they are unreliable. If the score is equal or more than the
reliable value, they are reliable. If they are neither reliable or unreliable
then they are neutral.
RecentCommentHistoryConfigurationInput controls the beheviour around when comments from Users
should be marked for pre-moderation automatically once they have reached the
trigger rate for rejected comments.
"""
input SettingsKarmaThresholdInput {
reliable: Int
unreliable: Int
}
input SettingsKarmaThresholdsInput {
input RecentCommentHistoryConfigurationInput {
"""
flag represents karma settings in relation to how well a User's flagging
ability aligns with the moderation decisions made by moderators.
"""
flag: SettingsKarmaThresholdInput
"""
comment represents the karma setting in relation to how well a User's comments are moderated.
"""
comment: SettingsKarmaThresholdInput
}
input SettingsKarmaInput {
"""
When true, checks will be completed to ensure that the Karma checks are
completed.
enabled when true will pre-moderate users new comments once they have reached
the trigger rejection rate.
"""
enabled: Boolean
"""
karmaThresholds contains the currently set thresholds for triggering Trust
behavior.
timeFrame specifies the number of seconds that comments from a given User will
be taken into account when computing their rejection rate.
"""
thresholds: SettingsKarmaThresholdsInput
timeFrame: Int
"""
triggerRejectionRate specifies the percentage of comments that a given User
may have before their comments will then be placed into pre-moderation until
the rejection rate decreases.
"""
triggerRejectionRate: Float
}
input SettingsCommunityGuidelinesInput {
@@ -3083,10 +3077,10 @@ input SettingsInput {
integrations: SettingsExternalIntegrationsInput
"""
karma is the set of settings related to how user Trust and Karma are
handled.
recentCommentHistory is the set of settings related to how automatic
pre-moderation is controlled.
"""
karma: SettingsKarmaInput
recentCommentHistory: RecentCommentHistoryConfigurationInput
"""
charCount stores the character count moderation settings.
@@ -34,9 +34,9 @@ import {
} from "coral-server/graph/common/extensions";
import { getOperationMetadata } from "coral-server/graph/common/extensions/helpers";
import logger from "coral-server/logger";
import { userIsStaff } from "coral-server/models/user/helpers";
import { extractTokenFromRequest } from "coral-server/services/jwt";
import { userIsStaff } from "coral-server/models/user/helpers";
import TenantContext, { TenantContextOptions } from "../context";
type OnConnectFn = (
+1 -1
View File
@@ -172,7 +172,7 @@ class Server {
logger.info("mongodb autoindexing is enabled, starting indexing");
await ensureIndexes(this.mongo);
} else {
logger.info("mongodb autoindexing is disabled, skipping indexing");
logger.warn("mongodb autoindexing is disabled, skipping indexing");
}
// Launch all of the job processors.
@@ -19,10 +19,10 @@ Object {
"reasons": Object {
"COMMENT_DETECTED_BANNED_WORD": 1,
"COMMENT_DETECTED_LINKS": 0,
"COMMENT_DETECTED_RECENT_HISTORY": 0,
"COMMENT_DETECTED_SPAM": 0,
"COMMENT_DETECTED_SUSPECT_WORD": 0,
"COMMENT_DETECTED_TOXIC": 0,
"COMMENT_DETECTED_TRUST": 0,
"COMMENT_REPORTED_OFFENSIVE": 0,
"COMMENT_REPORTED_OTHER": 1,
"COMMENT_REPORTED_SPAM": 0,
@@ -80,7 +80,7 @@ describe("#validateAction", () => {
},
{
actionType: ACTION_TYPE.FLAG,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_TRUST,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_RECENT_HISTORY,
},
{
actionType: ACTION_TYPE.FLAG,
+34 -13
View File
@@ -13,21 +13,20 @@ import {
GQLFlagActionCounts,
GQLReactionActionCounts,
} from "coral-server/graph/tenant/schema/__generated__/types";
import logger from "coral-server/logger";
import {
Connection,
ConnectionInput,
resolveConnection,
} from "coral-server/models/helpers/connection";
import {
createCollection,
createConnectionOrderVariants,
createIndexFactory,
} from "coral-server/models/helpers/indexing";
import Query, { FilterQuery } from "coral-server/models/helpers/query";
FilterQuery,
Query,
resolveConnection,
} from "coral-server/models/helpers";
import { TenantResource } from "coral-server/models/tenant";
function collection(mongo: Db) {
return mongo.collection<Readonly<CommentAction>>("commentActions");
}
const collection = createCollection<CommentAction>("commentActions");
export enum ACTION_TYPE {
/**
@@ -529,7 +528,7 @@ interface DecodedActionCountKey {
* decodeActionCountGroup will unpack the key as it is encoded into the separate
* actionType and reason.
*/
function decodeActionCountKey(key: string): DecodedActionCountKey {
function decodeActionCountKey(key: string): DecodedActionCountKey | null {
let actionType: string = "";
let reason: string = "";
@@ -546,12 +545,22 @@ function decodeActionCountKey(key: string): DecodedActionCountKey {
// Validate that the action type is flag.
if (actionType !== ACTION_TYPE.FLAG) {
throw new Error("invalid action type, expected only flag to have reason");
// This was an invalid action type.
logger.warn(
{ actionType },
"found an action type that should have been flag, but wasn't"
);
return null;
}
// Validate that the reason is valid.
if (!reason || !(reason in GQLCOMMENT_FLAG_REASON)) {
throw new Error("expected flag to have a reason that was valid");
// This was an invalid reason.
logger.warn(
{ reason: reason || null },
"found an invalid flagging reason"
);
return null;
}
} else {
actionType = key;
@@ -559,7 +568,12 @@ function decodeActionCountKey(key: string): DecodedActionCountKey {
// Validate that the action type is valid.
if (!actionType || !(actionType in ACTION_TYPE)) {
throw new Error("expected action to have an action type that was valid");
// This was an invalid flag given that the action type was invalid.
logger.warn(
{ actionType: actionType || null },
"found an invalid action type"
);
return null;
}
const result: DecodedActionCountKey = {
@@ -640,8 +654,15 @@ export function decodeActionCounts(
// Loop over all the encoded action counts to extract each of the action
// counts as they are encoded.
Object.entries(encodedActionCounts).forEach(([key, count]) => {
// Decode the encoded action count key.
const decoded = decodeActionCountKey(key);
if (!decoded) {
// If there was an error decoding the action count keys, skip this entry.
return;
}
// Pull out the action type and the reason from the key.
const { actionType, reason } = decodeActionCountKey(key);
const { actionType, reason } = decoded;
// Handle the different types and reasons.
incrementActionCounts(actionCounts, actionType, reason, count);
@@ -6,20 +6,17 @@ import { GQLCOMMENT_STATUS } from "coral-server/graph/tenant/schema/__generated_
import {
Connection,
ConnectionInput,
resolveConnection,
} from "coral-server/models/helpers/connection";
import {
createCollection,
createConnectionOrderVariants,
createIndexFactory,
} from "coral-server/models/helpers/indexing";
import Query from "coral-server/models/helpers/query";
Query,
resolveConnection,
} from "coral-server/models/helpers";
import { TenantResource } from "coral-server/models/tenant";
function collection(mongo: Db) {
return mongo.collection<Readonly<CommentModerationAction>>(
"commentModerationActions"
);
}
const collection = createCollection<CommentModerationAction>(
"commentModerationActions"
);
export async function createCommentModerationActionIndexes(mongo: Db) {
const createIndex = createIndexFactory(collection(mongo));
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,10 +1,10 @@
import { GQLCOMMENT_STATUS } from "coral-server/graph/tenant/schema/__generated__/types";
/**
* VISIBLE_STATUSES are the comment statuses that a Comment may have that would
* PUBLISHED_STATUSES are the comment statuses that a Comment may have that would
* make it visible to readers.
*/
export const VISIBLE_STATUSES = [
export const PUBLISHED_STATUSES = [
GQLCOMMENT_STATUS.NONE,
GQLCOMMENT_STATUS.APPROVED,
];
+42 -5
View File
@@ -1,5 +1,8 @@
import { Comment, Revision } from ".";
import { VISIBLE_STATUSES } from "./constants";
import { GQLCOMMENT_STATUS } from "coral-server/graph/tenant/schema/__generated__/types";
import { Comment } from "./comment";
import { PUBLISHED_STATUSES } from "./constants";
import { Revision } from "./revision";
/**
* hasAncestors will check to see if a given comment has any ancestors.
@@ -13,13 +16,13 @@ export function hasAncestors(
}
/**
* hasVisibleStatus will check to see if the comment has a visibility status
* hasPublishedStatus will check to see if the comment has a visibility status
* where readers could see it.
*
* @param comment the comment to check the status on
*/
export function hasVisibleStatus(comment: Pick<Comment, "status">): boolean {
return VISIBLE_STATUSES.includes(comment.status);
export function hasPublishedStatus(comment: Pick<Comment, "status">): boolean {
return PUBLISHED_STATUSES.includes(comment.status);
}
/**
@@ -32,3 +35,37 @@ export function getLatestRevision(
): Revision {
return comment.revisions[comment.revisions.length - 1];
}
// TODO: (wyattjoh) write a test to verify that this set of counts is always in sync with GQLCOMMENT_STATUS.
/**
* CommentStatusCounts stores the count of Comments that have the particular
* statuses.
*/
export interface CommentStatusCounts {
[GQLCOMMENT_STATUS.APPROVED]: number;
[GQLCOMMENT_STATUS.NONE]: number;
[GQLCOMMENT_STATUS.PREMOD]: number;
[GQLCOMMENT_STATUS.REJECTED]: number;
[GQLCOMMENT_STATUS.SYSTEM_WITHHELD]: number;
}
export function createEmptyCommentStatusCounts(): CommentStatusCounts {
return {
[GQLCOMMENT_STATUS.APPROVED]: 0,
[GQLCOMMENT_STATUS.NONE]: 0,
[GQLCOMMENT_STATUS.PREMOD]: 0,
[GQLCOMMENT_STATUS.REJECTED]: 0,
[GQLCOMMENT_STATUS.SYSTEM_WITHHELD]: 0,
};
}
export function calculateRejectionRate(counts: CommentStatusCounts): number {
const published = PUBLISHED_STATUSES.reduce(
(acc, status) => counts[status] + acc,
0
);
const rejected = counts[GQLCOMMENT_STATUS.REJECTED];
return rejected / (published + rejected);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,41 @@
import { EncodedCommentActionCounts } from "coral-server/models/action/comment";
export interface RevisionMetadata {
akismet?: boolean;
linkCount?: number;
perspective?: {
score: number;
model: string;
};
}
/**
* Revision stores a Comment's body for a specific edit. Actions can be tied to
* a Revision, as can moderation actions.
*/
export interface Revision {
/**
* id identifies this Revision.
*/
readonly id: string;
/**
* body is the body text for this revision.
*/
body: string;
/**
* actionCounts is the cached action counts on this revision.
*/
actionCounts: EncodedCommentActionCounts;
/**
* metadata stores properties on this revision.
*/
metadata: RevisionMetadata;
/**
* createdAt is the date that this revision was created at.
*/
createdAt: Date;
}
@@ -0,0 +1,7 @@
import { Db } from "mongodb";
export function createCollection<T>(name: string) {
return <U = T>(mongo: Db) => {
return mongo.collection<Readonly<U>>(name);
};
}
+1 -1
View File
@@ -1,6 +1,6 @@
import { merge } from "lodash";
import Query, { FilterQuery } from "coral-server/models/helpers/query";
import Query, { FilterQuery } from "./query";
export type Cursor = Date | number | string | null;
+5
View File
@@ -0,0 +1,5 @@
export * from "./collection";
export * from "./connection";
export * from "./indexing";
export { default as Query } from "./query";
export * from "./query";
+5 -4
View File
@@ -3,12 +3,13 @@ import uuid from "uuid";
import { Omit, Sub } from "coral-common/types";
import { GQLUSER_ROLE } from "coral-server/graph/tenant/schema/__generated__/types";
import { createIndexFactory } from "coral-server/models/helpers/indexing";
import {
createCollection,
createIndexFactory,
} from "coral-server/models/helpers";
import { TenantResource } from "coral-server/models/tenant";
function collection(mongo: Db) {
return mongo.collection<Readonly<Invite>>("invites");
}
const collection = createCollection<Readonly<Invite>>("invites");
export async function createInviteIndexes(mongo: Db) {
const createIndex = createIndexFactory(collection(mongo));
+2 -1
View File
@@ -80,7 +80,8 @@ export type Settings = GlobalModerationSettings &
Pick<
GQLSettings,
| "charCount"
| "karma"
| "email"
| "recentCommentHistory"
| "wordList"
| "integrations"
| "reaction"
@@ -1,9 +1,6 @@
import { GQLCOMMENT_STATUS } from "coral-server/graph/tenant/schema/__generated__/types";
import {
CommentModerationCountsPerQueue,
CommentModerationQueueCounts,
CommentStatusCounts,
} from ".";
export function createEmptyCommentModerationCountsPerQueue(): CommentModerationCountsPerQueue {
@@ -20,13 +17,3 @@ export function createEmptyCommentModerationQueueCounts(): CommentModerationQueu
queues: createEmptyCommentModerationCountsPerQueue(),
};
}
export function createEmptyCommentStatusCounts(): CommentStatusCounts {
return {
[GQLCOMMENT_STATUS.APPROVED]: 0,
[GQLCOMMENT_STATUS.NONE]: 0,
[GQLCOMMENT_STATUS.PREMOD]: 0,
[GQLCOMMENT_STATUS.REJECTED]: 0,
[GQLCOMMENT_STATUS.SYSTEM_WITHHELD]: 0,
};
}
+9 -19
View File
@@ -9,20 +9,24 @@ import { dotize } from "coral-common/utils/dotize";
import { GQLCOMMENT_STATUS } from "coral-server/graph/tenant/schema/__generated__/types";
import logger from "coral-server/logger";
import { EncodedCommentActionCounts } from "coral-server/models/action/comment";
import { createIndexFactory } from "coral-server/models/helpers/indexing";
import {
CommentStatusCounts,
createEmptyCommentStatusCounts,
} from "coral-server/models/comment/helpers";
import {
createCollection,
createIndexFactory,
} from "coral-server/models/helpers";
import { retrieveStory, Story } from "coral-server/models/story";
import { AugmentedRedis } from "coral-server/services/redis";
import { createEmptyCommentStatusCounts } from "./empty";
import { updateSharedCommentCounts } from "./shared";
/**
* collection provides a reference to the stories collection used by the
* counting system.
*/
function collection<T = Story>(mongo: Db) {
return mongo.collection<Readonly<T>>("stories");
}
const collection = createCollection<Story>("stories");
export async function createStoryCountIndexes(mongo: Db) {
const createIndex = createIndexFactory(collection(mongo));
@@ -31,20 +35,6 @@ export async function createStoryCountIndexes(mongo: Db) {
await createIndex({ tenantID: 1, createdAt: 1 }, { background: true });
}
// TODO: (wyattjoh) write a test to verify that this set of counts is always in sync with GQLCOMMENT_STATUS.
/**
* CommentStatusCounts stores the count of Comments that have the particular
* statuses.
*/
export interface CommentStatusCounts {
[GQLCOMMENT_STATUS.APPROVED]: number;
[GQLCOMMENT_STATUS.NONE]: number;
[GQLCOMMENT_STATUS.PREMOD]: number;
[GQLCOMMENT_STATUS.REJECTED]: number;
[GQLCOMMENT_STATUS.SYSTEM_WITHHELD]: number;
}
/**
* CommentModerationCountsPerQueue stores the number of Comments that exist in
* each of the Moderation Queues.
@@ -4,10 +4,14 @@ import ms from "ms";
import logger from "coral-server/logger";
import { EncodedCommentActionCounts } from "coral-server/models/action/comment";
import {
CommentStatusCounts,
createEmptyCommentStatusCounts,
} from "coral-server/models/comment/helpers";
import { createCollection } from "coral-server/models/helpers";
import { Story } from "coral-server/models/story";
import {
CommentModerationCountsPerQueue,
CommentStatusCounts,
StoryCounts,
} from "coral-server/models/story/counts";
import { AugmentedPipeline, AugmentedRedis } from "coral-server/services/redis";
@@ -15,7 +19,6 @@ import { AugmentedPipeline, AugmentedRedis } from "coral-server/services/redis";
import {
createEmptyCommentModerationCountsPerQueue,
createEmptyCommentModerationQueueCounts,
createEmptyCommentStatusCounts,
} from "./empty";
/**
@@ -47,9 +50,7 @@ const commentCountsModerationQueueQueuesKey = (tenantID: string) =>
* collection provides a reference to the stories collection used by the
* counting system.
*/
function collection<T = Story>(mongo: Db) {
return mongo.collection<Readonly<T>>("stories");
}
const collection = createCollection<Story>("stories");
/**
* recalculateSharedModerationQueueQueueCounts will reset the counts stored for
+6 -9
View File
@@ -11,28 +11,25 @@ import {
import {
Connection,
ConnectionInput,
resolveConnection,
} from "coral-server/models/helpers/connection";
import {
createConnectionOrderVariants,
createIndexFactory,
} from "coral-server/models/helpers/indexing";
import Query from "coral-server/models/helpers/query";
Query,
resolveConnection,
} from "coral-server/models/helpers";
import { GlobalModerationSettings } from "coral-server/models/settings";
import { TenantResource } from "coral-server/models/tenant";
import { createEmptyCommentStatusCounts } from "../comment/helpers";
import { createCollection } from "../helpers/collection";
import {
createEmptyCommentModerationQueueCounts,
createEmptyCommentStatusCounts,
StoryCommentCounts,
} from "./counts";
// Export everything under counts.
export * from "./counts";
function collection<T = Story>(mongo: Db) {
return mongo.collection<Readonly<T>>("stories");
}
const collection = createCollection<Story>("stories");
export type StorySettings = DeepPartial<
Pick<GQLStorySettings, "messageBox"> & GlobalModerationSettings
+12 -12
View File
@@ -9,12 +9,13 @@ import {
GQLMODERATION_MODE,
GQLSettings,
} from "coral-server/graph/tenant/schema/__generated__/types";
import { createIndexFactory } from "coral-server/models/helpers/indexing";
import {
createCollection,
createIndexFactory,
} from "coral-server/models/helpers";
import { Settings } from "coral-server/models/settings";
function collection(mongo: Db) {
return mongo.collection<Readonly<Tenant>>("tenants");
}
const collection = createCollection<Tenant>("tenants");
/**
* TenantResource references a given resource that should be owned by a specific
@@ -160,14 +161,13 @@ export async function createTenant(
enabled: false,
smtp: {},
},
karma: {
enabled: true,
thresholds: {
// By default, flaggers are reliable after one correct flag, and
// unreliable if there is an incorrect flag.
flag: { reliable: 1, unreliable: -1 },
comment: { reliable: 1, unreliable: -1 },
},
recentCommentHistory: {
enabled: false,
// 7 days in seconds.
timeFrame: 604800,
// Rejection rate defaulting to 30%, once exceeded, comments will be
// pre-moderated.
triggerRejectionRate: 0.3,
},
integrations: {
akismet: {
+5 -8
View File
@@ -27,20 +27,17 @@ import logger from "coral-server/logger";
import {
Connection,
ConnectionInput,
resolveConnection,
} from "coral-server/models/helpers/connection";
import {
createCollection,
createConnectionOrderVariants,
createIndexFactory,
} from "coral-server/models/helpers/indexing";
import Query from "coral-server/models/helpers/query";
Query,
resolveConnection,
} from "coral-server/models/helpers";
import { TenantResource } from "coral-server/models/tenant";
import { getLocalProfile, hasLocalProfile } from "./helpers";
function collection(mongo: Db) {
return mongo.collection<Readonly<User>>("users");
}
const collection = createCollection<User>("users");
export interface LocalProfile {
type: "local";
+1 -1
View File
@@ -16,11 +16,11 @@ import {
retrieveUserAction,
} from "coral-server/models/action/comment";
import {
getLatestRevision,
retrieveComment,
updateCommentActionCounts,
} from "coral-server/models/comment";
import { Comment } from "coral-server/models/comment";
import { getLatestRevision } from "coral-server/models/comment/helpers";
import {
updateStoryActionCounts,
updateStoryCounts,
+6 -4
View File
@@ -22,15 +22,15 @@ import {
CreateCommentInput,
editComment,
EditCommentInput,
getLatestRevision,
pushChildCommentIDOntoParent,
removeCommentTag,
retrieveComment,
validateEditable,
} from "coral-server/models/comment";
import {
getLatestRevision,
hasAncestors,
hasVisibleStatus,
hasPublishedStatus,
} from "coral-server/models/comment/helpers";
import {
retrieveStory,
@@ -95,7 +95,7 @@ export async function create(
}
// Check that the parent comment was visible.
if (!hasVisibleStatus(parent)) {
if (!hasPublishedStatus(parent)) {
throw new CommentNotFoundError(parent.id);
}
@@ -116,6 +116,7 @@ export async function create(
try {
// Run the comment through the moderation phases.
result = await processForModeration({
mongo,
nudge,
story,
tenant,
@@ -225,7 +226,7 @@ export async function create(
}
// If this comment is visible (and not a reply), publish it.
if (!input.parentID && hasVisibleStatus(comment)) {
if (!input.parentID && hasPublishedStatus(comment)) {
publishCommentCreated(publish, comment);
}
@@ -301,6 +302,7 @@ export async function edit(
// Run the comment through the moderation phases.
const { body, status, metadata, actions } = await processForModeration({
mongo,
story,
tenant,
comment: input,
@@ -1,3 +1,5 @@
import { Db } from "mongodb";
import { Omit, Promiseable, RequireProperty } from "coral-common/types";
import { GQLCOMMENT_STATUS } from "coral-server/graph/tenant/schema/__generated__/types";
import { CreateActionInput } from "coral-server/models/action/comment";
@@ -27,6 +29,7 @@ export interface PhaseResult {
}
export interface ModerationPhaseContext {
mongo: Db;
story: Story;
tenant: Tenant;
comment: RequireProperty<Partial<EditCommentInput>, "body">;
@@ -3,10 +3,10 @@ import { IntermediateModerationPhase } from "coral-server/services/comments/pipe
import { commentingDisabled } from "./commentingDisabled";
import { commentLength } from "./commentLength";
import { detectLinks } from "./detectLinks";
import { karma } from "./karma";
import { linkify } from "./linkify";
import { preModerate } from "./preModerate";
import { purify } from "./purify";
import { recentCommentHistory } from "./recentCommentHistory";
import { spam } from "./spam";
import { staff } from "./staff";
import { storyClosed } from "./storyClosed";
@@ -24,7 +24,7 @@ export const moderationPhases: IntermediateModerationPhase[] = [
purify,
wordList,
staff,
karma,
recentCommentHistory,
spam,
toxic,
detectLinks,
@@ -1,46 +0,0 @@
import {
GQLCOMMENT_FLAG_REASON,
GQLCOMMENT_STATUS,
} from "coral-server/graph/tenant/schema/__generated__/types";
import { ACTION_TYPE } from "coral-server/models/action/comment";
import {
IntermediateModerationPhase,
IntermediatePhaseResult,
} from "coral-server/services/comments/pipeline";
import {
getCommentTrustScore,
isReliableCommenter,
} from "coral-server/services/users/karma";
// This phase checks to see if the user making the comment is allowed to do so
// considering their reliability (Trust) status.
export const karma: IntermediateModerationPhase = ({
tenant,
author,
}): IntermediatePhaseResult | void => {
// If the user is not a reliable commenter (passed the unreliability
// threshold by having too many rejected comments) then we can change the
// status of the comment to `SYSTEM_WITHHELD`, therefore pushing the user's
// comments away from the public eye until a moderator can manage them. This
// of course can only be applied if the comment's current status is `NONE`,
// we don't want to interfere if the comment was rejected.
if (
tenant.karma.enabled &&
isReliableCommenter(tenant.karma.thresholds, author) === false
) {
// Add the flag related to Trust to the comment.
return {
status: GQLCOMMENT_STATUS.SYSTEM_WITHHELD,
actions: [
{
userID: null,
actionType: ACTION_TYPE.FLAG,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_TOXIC,
metadata: {
trust: getCommentTrustScore(author),
},
},
],
};
}
};
@@ -0,0 +1,61 @@
import { DateTime } from "luxon";
import {
GQLCOMMENT_FLAG_REASON,
GQLCOMMENT_STATUS,
} from "coral-server/graph/tenant/schema/__generated__/types";
import { ACTION_TYPE } from "coral-server/models/action/comment";
import {
calculateRejectionRate,
retrieveRecentStatusCounts,
} from "coral-server/models/comment";
import {
IntermediatePhaseResult,
ModerationPhaseContext,
} from "coral-server/services/comments/pipeline";
export const recentCommentHistory = async ({
tenant,
author,
mongo,
now,
}: Pick<
ModerationPhaseContext,
"author" | "tenant" | "now" | "mongo"
>): Promise<IntermediatePhaseResult | void> => {
// Ensure this mode is enabled.
if (!tenant.recentCommentHistory.enabled) {
return;
}
// Get the time frame.
const since = DateTime.fromJSDate(now)
.plus({ seconds: -tenant.recentCommentHistory.timeFrame })
.toJSDate();
// Get the comment rates for this User.
const counts = await retrieveRecentStatusCounts(
mongo,
tenant.id,
since,
author.id
);
// Get the rejection rate.
const rate = calculateRejectionRate(counts);
if (rate >= tenant.recentCommentHistory.triggerRejectionRate) {
return {
status: GQLCOMMENT_STATUS.SYSTEM_WITHHELD,
actions: [
{
userID: null,
actionType: ACTION_TYPE.FLAG,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_RECENT_HISTORY,
metadata: {
rate,
},
},
],
};
}
};
+3 -3
View File
@@ -4,7 +4,7 @@ import {
GQLMODERATION_QUEUE,
} from "coral-server/graph/tenant/schema/__generated__/types";
import { Publisher } from "coral-server/graph/tenant/subscriptions/publisher";
import { Comment, hasVisibleStatus } from "coral-server/models/comment";
import { Comment, hasPublishedStatus } from "coral-server/models/comment";
import { CommentModerationQueueCounts } from "coral-server/models/story/counts";
export function publishCommentStatusChanges(
@@ -31,7 +31,7 @@ export function publishCommentReplyCreated(
publish: Publisher,
comment: Pick<Comment, "id" | "status" | "ancestorIDs">
) {
if (comment.ancestorIDs.length > 0 && hasVisibleStatus(comment)) {
if (comment.ancestorIDs.length > 0 && hasPublishedStatus(comment)) {
publish({
channel: SUBSCRIPTION_CHANNELS.COMMENT_REPLY_CREATED,
payload: {
@@ -46,7 +46,7 @@ export function publishCommentCreated(
publish: Publisher,
comment: Pick<Comment, "id" | "storyID" | "parentID" | "status">
) {
if (!comment.parentID && hasVisibleStatus(comment)) {
if (!comment.parentID && hasPublishedStatus(comment)) {
publish({
channel: SUBSCRIPTION_CHANNELS.COMMENT_CREATED,
payload: {

Some files were not shown because too many files have changed in this diff Show More