diff --git a/INDEXES.md b/INDEXES.md index 564e05c52b..77f282603a 100644 --- a/INDEXES.md +++ b/INDEXES.md @@ -6,6 +6,20 @@ The goal of this document is to date-mark the indexes you add to support the cha If you are releasing, you can use this readme to check all the indexes prior to the release you are deploying and have a good idea of what indexes you might need to deploy to Mongo along with your release of a new Coral Docker image to kubernetes. +## 2023-10-18 + +``` +db.notifications.createIndex({ tenantID: 1, id: 1 }, { unique: true }); +``` + +- This index creates the uniqueness constraint for the `tenantID` and `id` fields on the notifications collection + +``` +db.notifications.createIndex({ tenantID: 1, ownerID: 1, createdAt: 1 }); +``` + +- This index speeds up the retrieval of notifications by `tenantID`, `ownerID`, and `createdAt` which is the most common way of retrieving notifications for pagination in the notifications tab on the stream. + ## 2023-03-28 ``` diff --git a/client/package-lock.json b/client/package-lock.json index 939da05989..f91890424f 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,12 +1,12 @@ { "name": "@coralproject/talk", - "version": "8.5.3", + "version": "8.6.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@coralproject/talk", - "version": "8.5.3", + "version": "8.6.0", "license": "Apache-2.0", "dependencies": { "@ampproject/toolbox-cache-url": "^2.9.0", diff --git a/client/package.json b/client/package.json index 6773fa946a..29fbbea7a6 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "@coralproject/talk", - "version": "8.5.3", + "version": "8.6.0", "author": "The Coral Project", "homepage": "https://coralproject.net/", "sideEffects": [ diff --git a/client/scripts/precommitLint.js b/client/scripts/precommitLint.js index ebb883f587..140103e604 100644 --- a/client/scripts/precommitLint.js +++ b/client/scripts/precommitLint.js @@ -26,12 +26,12 @@ sgf((err, results) => { const eslintFiles = []; for (const item of results) { - const { filename } = item; + const { filename, status } = item; // only include valid, filtered extensions // this is primarily to keep eslint rampaging // over non-source files - if (!matchesExtension(extensions, filename)) { + if (!matchesExtension(extensions, filename) || status === "Deleted") { continue; } diff --git a/client/src/core/client/admin/App/Main.tsx b/client/src/core/client/admin/App/Main.tsx index 1108c0af89..f4114d2fa0 100644 --- a/client/src/core/client/admin/App/Main.tsx +++ b/client/src/core/client/admin/App/Main.tsx @@ -18,17 +18,18 @@ import styles from "./Main.css"; interface Props { viewer: PropTypesOf["viewer"] & PropTypesOf["viewer"]; + settings: PropTypesOf["settings"]; children: React.ReactNode; } -const Main: FunctionComponent = ({ children, viewer }) => ( +const Main: FunctionComponent = ({ children, viewer, settings }) => (
- +
diff --git a/client/src/core/client/admin/App/MainRoute.tsx b/client/src/core/client/admin/App/MainRoute.tsx index a4a3002e5a..f9c255f3e4 100644 --- a/client/src/core/client/admin/App/MainRoute.tsx +++ b/client/src/core/client/admin/App/MainRoute.tsx @@ -20,7 +20,12 @@ const MainRoute: React.FunctionComponent = (props) => { return ( <> {ErrorReporterSetUser} -
{props.children}
+
+ {props.children} +
); }; @@ -33,6 +38,9 @@ const enhanced = withRouteConfig({ ...UserMenuContainer_viewer ...NavigationContainer_viewer } + settings { + ...NavigationContainer_settings + } } `, })(MainRoute); diff --git a/client/src/core/client/admin/App/Navigation/Navigation.tsx b/client/src/core/client/admin/App/Navigation/Navigation.tsx index 4e2c54cbf8..852cd9878c 100644 --- a/client/src/core/client/admin/App/Navigation/Navigation.tsx +++ b/client/src/core/client/admin/App/Navigation/Navigation.tsx @@ -8,6 +8,7 @@ import NavigationLink from "./NavigationLink"; interface Props { showConfigure: boolean; showDashboard: boolean; + showReports: boolean; } const Navigation: FunctionComponent = (props) => ( @@ -31,6 +32,11 @@ const Navigation: FunctionComponent = (props) => ( Dashboard )} + {props.showReports && ( + + DSA Reports + + )} ); diff --git a/client/src/core/client/admin/App/Navigation/NavigationContainer.tsx b/client/src/core/client/admin/App/Navigation/NavigationContainer.tsx index 08f9b1b42a..8141bb8621 100644 --- a/client/src/core/client/admin/App/Navigation/NavigationContainer.tsx +++ b/client/src/core/client/admin/App/Navigation/NavigationContainer.tsx @@ -1,43 +1,73 @@ -import React from "react"; +import React, { FunctionComponent } from "react"; import { graphql } from "react-relay"; import { Ability, can } from "coral-admin/permissions/tenant"; -import { withFragmentContainer } from "coral-framework/lib/relay"; +import { GQLUSER_ROLE } from "coral-common/client/src/core/client/framework/schema/__generated__/types"; +import { useLocal, withFragmentContainer } from "coral-framework/lib/relay"; import { SignOutMutation, withSignOutMutation, } from "coral-framework/mutations"; +import { NavigationContainer_settings as SettingsData } from "coral-admin/__generated__/NavigationContainer_settings.graphql"; import { NavigationContainer_viewer as ViewerData } from "coral-admin/__generated__/NavigationContainer_viewer.graphql"; +import { NavigationContainerLocal } from "coral-admin/__generated__/NavigationContainerLocal.graphql"; import Navigation from "./Navigation"; interface Props { signOut: SignOutMutation; viewer: ViewerData | null; + settings: SettingsData | null; } -class NavigationContainer extends React.Component { - public render() { - return ( - - ); - } -} +const NavigationContainer: FunctionComponent = ({ + viewer, + settings, +}) => { + const [{ dsaFeaturesEnabled }] = useLocal( + graphql` + fragment NavigationContainerLocal on Local { + dsaFeaturesEnabled + } + ` + ); + return ( + + ); +}; const enhanced = withSignOutMutation( withFragmentContainer({ viewer: graphql` fragment NavigationContainer_viewer on User { role + moderationScopes { + sites { + id + } + } + } + `, + settings: graphql` + fragment NavigationContainer_settings on Settings { + multisite } `, })(NavigationContainer) diff --git a/client/src/core/client/admin/components/Comment/InReplyTo.tsx b/client/src/core/client/admin/components/Comment/InReplyTo.tsx index 4d0d4fe44a..725bfb1d4e 100644 --- a/client/src/core/client/admin/components/Comment/InReplyTo.tsx +++ b/client/src/core/client/admin/components/Comment/InReplyTo.tsx @@ -1,4 +1,5 @@ import { Localized } from "@fluent/react/compat"; +import cn from "classnames"; import React, { FunctionComponent } from "react"; import { EmailActionReplyIcon, SvgIcon } from "coral-ui/components/icons"; @@ -7,11 +8,16 @@ import { BaseButton, Flex } from "coral-ui/components/v2"; import styles from "./InReplyTo.css"; interface Props { + className?: string; children: string; onUsernameClick: () => void; } -const InReplyTo: FunctionComponent = ({ children, onUsernameClick }) => { +const InReplyTo: FunctionComponent = ({ + className, + children, + onUsernameClick, +}) => { const Username = () => ( = ({ children, onUsernameClick }) => { vars={{ username: children }} > - {children} + {children} ); @@ -29,7 +35,7 @@ const InReplyTo: FunctionComponent = ({ children, onUsernameClick }) => { {" "} = ({ const rejectComment = useMutation(RejectCommentMutation); const { match } = useRouter(); const { storyID, siteID, section } = parseModerationOptions(match); - const [{ moderationQueueSort }] = + const [{ moderationQueueSort, dsaFeaturesEnabled }] = useLocal(graphql` fragment ConversationModalCommentContainerLocal on Local { moderationQueueSort + dsaFeaturesEnabled } `); const commentAuthorClick = useCallback(() => { @@ -72,28 +78,32 @@ const ConversationModalCommentContainer: FunctionComponent = ({ const onShowReplies = useCallback(() => { setShowReplies(true); }, []); - const onRejectComment = useCallback(async () => { - if (!comment.revision) { - return; - } - await rejectComment({ - commentID: comment.id, - commentRevisionID: comment.revision.id, + const onRejectComment = useCallback( + async (reason?: RejectCommentReasonInput) => { + if (!comment.revision) { + return; + } + await rejectComment({ + commentID: comment.id, + commentRevisionID: comment.revision.id, + storyID, + siteID, + section, + orderBy: moderationQueueSort, + reason, + }); + }, + [ + comment.id, + comment.revision, + match, + moderationQueueSort, + rejectComment, storyID, siteID, section, - orderBy: moderationQueueSort, - }); - }, [ - comment.id, - comment.revision, - match, - moderationQueueSort, - rejectComment, - storyID, - siteID, - section, - ]); + ] + ); const rejectButtonOptions = useMemo((): { localization: string; variant: "regular" | "outlined"; @@ -170,24 +180,54 @@ const ConversationModalCommentContainer: FunctionComponent = ({ - }} + ( + + + { + void onRejectComment(reason); + toggleVisibility(); + }} + /> + + + )} > - - + {({ toggleVisibility, ref }) => ( + }} + > + + + )} + diff --git a/client/src/core/client/admin/components/ModerateCard/MarkersContainer.tsx b/client/src/core/client/admin/components/ModerateCard/MarkersContainer.tsx index a775ec64aa..237fb98df8 100644 --- a/client/src/core/client/admin/components/ModerateCard/MarkersContainer.tsx +++ b/client/src/core/client/admin/components/ModerateCard/MarkersContainer.tsx @@ -182,6 +182,16 @@ const markers: Array< )) || null, + (c) => + (c.revision && c.revision.actionCounts.illegal.total > 0 && ( + + + Illegal content + {" "} + {c.revision.actionCounts.illegal.total} + + )) || + null, ]; export const MarkersContainer: React.FunctionComponent< @@ -231,6 +241,9 @@ const enhanced = withFragmentContainer({ } revision { actionCounts { + illegal { + total + } flag { reasons { COMMENT_REPORTED_SPAM diff --git a/client/src/core/client/admin/components/ModerateCard/ModerateCard.css b/client/src/core/client/admin/components/ModerateCard/ModerateCard.css index 24c7c0dbef..ae2e32d7f4 100644 --- a/client/src/core/client/admin/components/ModerateCard/ModerateCard.css +++ b/client/src/core/client/admin/components/ModerateCard/ModerateCard.css @@ -111,6 +111,11 @@ $moderateCardLinkTextColor: $colors-teal-700; padding-bottom: var(--spacing-1); } +.moderationReasonCard { + padding: 0; + margin: 0; +} + .storyLabel { color: var(--palette-grey-500); font-size: var(--font-size-1); diff --git a/client/src/core/client/admin/components/ModerateCard/ModerateCard.tsx b/client/src/core/client/admin/components/ModerateCard/ModerateCard.tsx index e340c5ed1c..23769bd620 100644 --- a/client/src/core/client/admin/components/ModerateCard/ModerateCard.tsx +++ b/client/src/core/client/admin/components/ModerateCard/ModerateCard.tsx @@ -23,14 +23,20 @@ import { import { Button, Card, + ClickOutside, + Dropdown, Flex, HorizontalGutter, + Popover, TextLink, Timestamp, } from "coral-ui/components/v2"; import { StarRating } from "coral-ui/components/v3"; +import { RejectCommentReasonInput } from "coral-stream/__generated__/RejectCommentMutation.graphql"; + import { CommentContent, InReplyTo, UsernameButton } from "../Comment"; +import ModerationReason from "../ModerationReason/ModerationReason"; import ApproveButton from "./ApproveButton"; import CommentAuthorContainer from "./CommentAuthorContainer"; import FeatureButton from "./FeatureButton"; @@ -95,6 +101,8 @@ interface Props { suspectWords?: Readonly[]>; isArchived?: boolean; isArchiving?: boolean; + dsaFeaturesEnabled: boolean; + onReason: (reason: RejectCommentReasonInput) => void; } const ModerateCard: FunctionComponent = ({ @@ -137,6 +145,8 @@ const ModerateCard: FunctionComponent = ({ suspectWords, isArchived, isArchiving, + onReason, + dsaFeaturesEnabled, }) => { const div = useRef(null); @@ -341,22 +351,54 @@ const ModerateCard: FunctionComponent = ({ )} - + { + return ( + + + { + onReason(reason); + toggleVisibility(); + }} + onCancel={toggleVisibility} + id={id} + /> + + + ); + }} + placement="bottom-start" + > + {({ toggleVisibility, visible, ref }) => { + return ( + + ); + }} + t.code === GQLTAG.FEATURED); } +interface RejectionState { + showModerationReason: boolean; + rejecting: boolean; + reason?: RejectCommentInput["reason"]; +} + +type RejectionAction = + | { action: "INDICATE_REJECT" } + | { action: "CONFIRM_REASON"; reason: RejectCommentInput["reason"] } + | { action: "REJECTION_COMPLETE" }; + +type RejectionReducer = Reducer; + const ModerateCardContainer: FunctionComponent = ({ comment, settings, @@ -94,10 +111,11 @@ const ModerateCardContainer: FunctionComponent = ({ const { match, router } = useRouter(); - const [{ moderationQueueSort }] = + const [{ moderationQueueSort, dsaFeaturesEnabled }] = useLocal(graphql` fragment ModerateCardContainerLocal on Local { moderationQueueSort + dsaFeaturesEnabled } `); @@ -109,6 +127,27 @@ const ModerateCardContainer: FunctionComponent = ({ ); const [showBanModal, setShowBanModal] = useState(false); + const [{ rejecting, reason: rejectionReason }, dispatch] = + useReducer( // TODO use dispatch + (state, input) => { + switch (input.action) { + case "INDICATE_REJECT": + return dsaFeaturesEnabled + ? { showModerationReason: true, rejecting: false } + : { showModerationReason: false, rejecting: true }; + case "CONFIRM_REASON": + return { + showModerationReason: false, + rejecting: true, + reason: input.reason, + }; + case "REJECTION_COMPLETE": + return { showModerationReason: false, rejecting: false }; + } + }, + { showModerationReason: false, rejecting: false } + ); + const handleApprove = useCallback(async () => { if (!comment.revision) { return; @@ -159,11 +198,15 @@ const ModerateCardContainer: FunctionComponent = ({ await rejectComment({ commentID: comment.id, commentRevisionID: comment.revision.id, + reason: rejectionReason, storyID, siteID, section, orderBy: moderationQueueSort, }); + + dispatch({ action: "REJECTION_COMPLETE" }); + if (loadNext) { loadNext(); } @@ -179,6 +222,7 @@ const ModerateCardContainer: FunctionComponent = ({ loadNext, moderationQueueSort, onModerated, + rejectionReason, ]); const handleFeature = useCallback(() => { @@ -276,6 +320,12 @@ const ModerateCardContainer: FunctionComponent = ({ const handleBanConfirm = useCallback(() => setShowBanModal(false), []); + useEffect(() => { + if (rejecting) { + void handleReject(); + } + }, [handleReject, rejecting]); + // Only highlight comments that have been flagged for containing a banned or // suspect word. const highlight = useMemo(() => { @@ -319,7 +369,7 @@ const ModerateCardContainer: FunctionComponent = ({ featured={isFeatured(comment)} viewContextHref={comment.permalink} onApprove={handleApprove} - onReject={handleReject} + onReject={() => dispatch({ action: "INDICATE_REJECT" })} onFeature={onFeature} onUsernameClick={onUsernameClicked} onConversationClick={ @@ -357,6 +407,8 @@ const ModerateCardContainer: FunctionComponent = ({ } isArchived={comment.story.isArchived} isArchiving={comment.story.isArchiving} + dsaFeaturesEnabled={!!dsaFeaturesEnabled} + onReason={(reason) => dispatch({ action: "CONFIRM_REASON", reason })} /> {comment.author && ( diff --git a/client/src/core/client/admin/components/ModerateCard/RejectButton.css b/client/src/core/client/admin/components/ModerateCard/RejectButton.css index 2acecfb24a..8eac232e71 100644 --- a/client/src/core/client/admin/components/ModerateCard/RejectButton.css +++ b/client/src/core/client/admin/components/ModerateCard/RejectButton.css @@ -6,9 +6,7 @@ $moderateCardButtonOutlineRejectColor: var(--palette-error-500); border-radius: var(--round-corners); width: 65px; height: 50px; - display: flex; - justify-content: center; - align-items: center; + position: relative; color: $moderateCardButtonOutlineRejectColor; &:not(:disabled):active { background-color: $moderateCardButtonOutlineRejectColor; @@ -16,6 +14,11 @@ $moderateCardButtonOutlineRejectColor: var(--palette-error-500); } } +.open { + background-color: var(--palette-error-400); + color: var(--palette-text-000); +} + .readOnly { background-color: transparent; border-color: $colors-grey-300); @@ -42,6 +45,17 @@ $moderateCardButtonOutlineRejectColor: var(--palette-error-500); border-color: $moderateCardButtonOutlineRejectColor; } -.icon { +.xIcon { + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); color: inherit; } + +.arrowIcon { + position: absolute; + left: 75%; + top: 25%; + transform: translate(-50%, 25%); +} diff --git a/client/src/core/client/admin/components/ModerateCard/RejectButton.tsx b/client/src/core/client/admin/components/ModerateCard/RejectButton.tsx index 3bfc1aba47..34af51ee2f 100644 --- a/client/src/core/client/admin/components/ModerateCard/RejectButton.tsx +++ b/client/src/core/client/admin/components/ModerateCard/RejectButton.tsx @@ -3,34 +3,55 @@ import cn from "classnames"; import React, { FunctionComponent } from "react"; import { PropTypesOf } from "coral-framework/types"; -import { RemoveIcon, SvgIcon } from "coral-ui/components/icons"; +import { + ArrowsDownIcon, + ArrowsUpIcon, + RemoveIcon, + SvgIcon, +} from "coral-ui/components/icons"; import { BaseButton } from "coral-ui/components/v2"; +import { withForwardRef } from "coral-ui/hocs"; import styles from "./RejectButton.css"; interface Props extends Omit, "ref"> { + toggle?: boolean; + open?: boolean; invert?: boolean; readOnly?: boolean; + forwardRef: React.Ref; } const RejectButton: FunctionComponent = ({ invert, readOnly, className, + open = false, + toggle = false, + forwardRef, ...rest }) => ( - + + {toggle && ( + + )} ); -export default RejectButton; +export default withForwardRef(RejectButton); diff --git a/client/src/core/client/admin/components/ModerationReason/DetailedExplanation.css b/client/src/core/client/admin/components/ModerationReason/DetailedExplanation.css new file mode 100644 index 0000000000..c325296869 --- /dev/null +++ b/client/src/core/client/admin/components/ModerationReason/DetailedExplanation.css @@ -0,0 +1,21 @@ +.detailedExplanation { + margin-bottom: var(--spacing-3); +} + +.explanationLabel { + margin-bottom: var(--spacing-2); + margin-top: var(--spacing-2); +} + +.changeReason { + margin-bottom: var(--spacing-3); + padding: 0; +} + +.detailedExplanation > textarea { + resize: none; +} + +.code { + font-family: var(--font-family-primary); +} diff --git a/client/src/core/client/admin/components/ModerationReason/DetailedExplanation.tsx b/client/src/core/client/admin/components/ModerationReason/DetailedExplanation.tsx new file mode 100644 index 0000000000..8c9ec0774a --- /dev/null +++ b/client/src/core/client/admin/components/ModerationReason/DetailedExplanation.tsx @@ -0,0 +1,93 @@ +import { Localized } from "@fluent/react/compat"; +import cn from "classnames"; +import React, { FunctionComponent, useState } from "react"; + +import { GQLREJECTION_REASON_CODE } from "coral-framework/schema"; +import { Label } from "coral-ui/components/v2"; +import { TextArea } from "coral-ui/components/v3"; +import { Button } from "coral-ui/components/v3/Button/Button"; + +import { unsnake } from "./formatting"; + +import styles from "./DetailedExplanation.css"; +import commonStyles from "./ModerationReason.css"; + +export interface Props { + onChange: (value: string) => void; + code: GQLREJECTION_REASON_CODE; + value: string | null; + onBack: () => void; +} + +const AddExplanationButton: FunctionComponent<{ onClick: () => void }> = ({ + onClick, +}) => ( + + + +); + +const DetailedExplanation: FunctionComponent = ({ + code, + value, + onChange, + onBack, +}) => { + const [showAddExplanation, setShowAddExplanation] = useState( + !!(code === GQLREJECTION_REASON_CODE.OTHER) + ); + + return ( + <> + + + + + + + + + +
{unsnake(code)}
+
+ + {showAddExplanation ? ( + <> + + + + + +