mirror of
https://github.com/wassname/talk.git
synced 2026-06-27 18:07:26 +08:00
[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:
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"ms-vscode.vscode-typescript-tslint-plugin",
|
||||
"kumar-harsh.graphql-for-vscode",
|
||||
"editorconfig.editorconfig",
|
||||
"ms-azuretools.vscode-cosmosdb"
|
||||
]
|
||||
}
|
||||
Vendored
+6
-5
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+1
-1
@@ -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
|
||||
}
|
||||
|
||||
@@ -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!}
|
||||
|
||||
+6
-6
@@ -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);
|
||||
}
|
||||
|
||||
+141
-23
@@ -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}
|
||||
|
||||
+1
@@ -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));
|
||||
}
|
||||
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
.thresholdTextField {
|
||||
width: calc(6 * var(--mini-unit));
|
||||
}
|
||||
+133
@@ -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 commenter’s 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;
|
||||
+39
@@ -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
@@ -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 commenter’s 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"
|
||||
>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
+3
-3
@@ -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>
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
+2
-2
@@ -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..." });
|
||||
}));
|
||||
|
||||
|
||||
@@ -375,7 +375,7 @@ export const baseStory = createFixture<GQLStory>({
|
||||
},
|
||||
},
|
||||
commentCounts: {
|
||||
totalVisible: 0,
|
||||
totalPublished: 0,
|
||||
tags: {
|
||||
FEATURED: 0,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
-6
@@ -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";
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
),
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from "./collection";
|
||||
export * from "./connection";
|
||||
export * from "./indexing";
|
||||
export { default as Query } from "./query";
|
||||
export * from "./query";
|
||||
@@ -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));
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -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
Reference in New Issue
Block a user