diff --git a/client/src/components/ItemAction.tsx b/client/src/components/ItemAction.tsx index d033713..d3d24e7 100644 --- a/client/src/components/ItemAction.tsx +++ b/client/src/components/ItemAction.tsx @@ -1,21 +1,21 @@ -import ActionParametersModal, { - defaultValuesForParameters, -} from '@/components/ActionParametersModal'; -import { type ActionParameterValues } from '@/components/ActionParameterInputs'; -import { type JsonObject } from 'type-fest'; import { - type GQLActionParameter, namedOperations, useGQLBulkActionExecutionMutation, useGQLBulkActionsFormDataQuery, + type GQLActionParameter, } from '@/graphql/generated'; import { stripTypename } from '@/graphql/inputHelpers'; -import { ItemIdentifier } from '@roostorg/types'; import Pencil from '@/icons/lni/Education/pencil.svg?react'; +import { ItemIdentifier } from '@roostorg/types'; import { Button, Input, Select } from 'antd'; import orderBy from 'lodash/orderBy'; import { useCallback, useMemo, useState } from 'react'; +import { type JsonObject } from 'type-fest'; +import { type ActionParameterValues } from '@/components/ActionParameterInputs'; +import ActionParametersModal, { + defaultValuesForParameters, +} from '@/components/ActionParametersModal'; import { selectFilterByLabelOption } from '@/webpages/dashboard/components/antDesignUtils'; import CoopButton from '@/webpages/dashboard/components/CoopButton'; import CoopModal from '@/webpages/dashboard/components/CoopModal'; @@ -50,7 +50,9 @@ export default function ItemAction(props: { const anyFailed = results.some((r) => r.success === false); if (anyFailed) { setModalBody( - 'One or more actions failed. The callback URL may have returned an error. If your org requires a policy for decisions, select a policy and try again.', + 'One or more actions failed. Check server logs for details. ' + + 'If the action posts to a callback URL, the URL may have returned an error. ' + + 'If your org requires a policy for decisions, select a policy and try again.', ); } else { setModalBody('Actions submitted successfully.'); @@ -76,7 +78,9 @@ export default function ItemAction(props: { const [moderatorNote, setModeratorNote] = useState(''); const eligibleActions: EligibleAction[] = (queryData?.myOrg?.actions ?? []) - .filter((it) => it.itemTypes.map((t) => t.id).includes(itemIdentifier.typeId)) + .filter((it) => + it.itemTypes.map((t) => t.id).includes(itemIdentifier.typeId), + ) .map((it) => ({ id: it.id, name: it.name, @@ -211,7 +215,8 @@ export default function ItemAction(props: { Object.keys(parametersByActionId).length > 0 ? (parametersByActionId as unknown as JsonObject) : undefined, - note: moderatorNote.trim() === '' ? undefined : moderatorNote.trim(), + note: + moderatorNote.trim() === '' ? undefined : moderatorNote.trim(), }, }, }), diff --git a/client/src/components/common/CoopSelect.tsx b/client/src/components/common/CoopSelect.tsx index 997677c..4726c29 100644 --- a/client/src/components/common/CoopSelect.tsx +++ b/client/src/components/common/CoopSelect.tsx @@ -1,3 +1,4 @@ +import { isTypingInEditableElement } from '@/utils/misc'; import { useCallback, useEffect, useRef, useState } from 'react'; import TextToken from '../../webpages/dashboard/components/TextToken'; @@ -148,6 +149,9 @@ export default function CoopSelect(props: { if (disabled) { return; } + if (isTypingInEditableElement(event.target)) { + return; + } if (!isMenuVisible) { if (event.key === openDropdownKeyBinding) { setIsMenuVisible(true); @@ -272,8 +276,8 @@ export default function CoopSelect(props: { selectedOptions.includes(option.value) ? 'bg-coop-lightblue' : focusedOption === option.value - ? 'bg-slate-100' - : 'bg-white' + ? 'bg-slate-100' + : 'bg-white' } hover:bg-coop-lightblue-hover whitespace-nowrap`} onClick={() => toggleOption(option.value)} > diff --git a/client/src/graphql/generated.ts b/client/src/graphql/generated.ts index aeea72e..defaba5 100644 --- a/client/src/graphql/generated.ts +++ b/client/src/graphql/generated.ts @@ -2452,6 +2452,12 @@ export type GQLMutation = { readonly reorderRoutingRules: GQLReorderRoutingRulesResponse; readonly requestDemo?: Maybe; readonly resetPassword: Scalars['Boolean']['output']; + /** + * Retries a previously-failed NCMEC submission. Org-scoped: callers can only + * retry decisions that belong to their own org. Returns success on a fresh + * successful submission, or an error with a user-safe summary on failure. + */ + readonly retryNcmecSubmission: GQLRetryNcmecSubmissionResponse; readonly rotateApiKey: GQLRotateApiKeyResponse; readonly rotateWebhookSigningKey: GQLRotateWebhookSigningKeyResponse; readonly runRetroaction?: Maybe; @@ -2683,6 +2689,10 @@ export type GQLMutationResetPasswordArgs = { input: GQLResetPasswordInput; }; +export type GQLMutationRetryNcmecSubmissionArgs = { + decisionId: Scalars['ID']['input']; +}; + export type GQLMutationRotateApiKeyArgs = { input: GQLRotateApiKeyInput; }; @@ -2879,6 +2889,33 @@ export type GQLNcmecContentItem = { readonly isReported: Scalars['Boolean']['output']; }; +/** + * An NCMEC submission that was decisioned in the MRT but never produced a + * successful CyberTip report. Reused on the NCMEC Reports dashboard so that + * reviewers can see and retry failed submissions in the same place as + * successful reports. The userId + userItemTypeId pair uniquely identifies + * the reported user; decisionId is the stable handle for retrying. + */ +export type GQLNcmecFailedSubmission = { + readonly __typename: 'NcmecFailedSubmission'; + readonly decisionId: Scalars['ID']['output']; + readonly lastError?: Maybe; + readonly retryCount: Scalars['Int']['output']; + readonly reviewerId?: Maybe; + readonly status: GQLNcmecFailedSubmissionStatus; + readonly ts: Scalars['DateTime']['output']; + readonly userId: Scalars['String']['output']; + readonly userItemType: GQLUserItemType; +}; + +export const GQLNcmecFailedSubmissionStatus = { + NeverAttempted: 'NEVER_ATTEMPTED', + PermanentError: 'PERMANENT_ERROR', + RetryableError: 'RETRYABLE_ERROR', +} as const; + +export type GQLNcmecFailedSubmissionStatus = + (typeof GQLNcmecFailedSubmissionStatus)[keyof typeof GQLNcmecFailedSubmissionStatus]; export const GQLNcmecFileAnnotation = { AnimeDrawingVirtualHentai: 'ANIME_DRAWING_VIRTUAL_HENTAI', Bestiality: 'BESTIALITY', @@ -3044,6 +3081,12 @@ export type GQLOrg = { readonly contentTypes: ReadonlyArray; readonly defaultInterfacePreferences: GQLUserInterfacePreferences; readonly email: Scalars['String']['output']; + /** + * NCMEC decisions that did not produce a successful CyberTip report. Returned + * alongside ncmecReports on the dashboard so reviewers can see the full + * submission status (successful + failed) in one place and retry failures. + */ + readonly failedNcmecSubmissions: ReadonlyArray; readonly hasAppealsEnabled: Scalars['Boolean']['output']; readonly hasNCMECReportingEnabled: Scalars['Boolean']['output']; readonly hasPartialItemsEndpoint: Scalars['Boolean']['output']; @@ -3804,6 +3847,16 @@ export type GQLResolvedJobCount = { readonly time: Scalars['String']['output']; }; +export type GQLRetryNcmecSubmissionResponse = { + readonly __typename: 'RetryNcmecSubmissionResponse'; + /** + * Human-readable error summary if the retry failed. Never includes raw + * NCMEC response bodies; safe to render in the UI. + */ + readonly error?: Maybe; + readonly success: Scalars['Boolean']['output']; +}; + export type GQLRotateApiKeyError = GQLError & { readonly __typename: 'RotateApiKeyError'; readonly detail?: Maybe; @@ -4323,6 +4376,7 @@ export type GQLSubmitNcmecReportDecisionComponent = }; export type GQLSubmitNcmecReportInput = { + readonly additionalInfo?: InputMaybe; readonly escalateToHighPriority?: InputMaybe; readonly incidentType: GQLNcmecIncidentType; readonly reportedMedia: ReadonlyArray; @@ -18382,6 +18436,21 @@ export type GQLNcmecReportValuesFragment = { }>; }; +export type GQLNcmecFailedSubmissionValuesFragment = { + readonly __typename: 'NcmecFailedSubmission'; + readonly decisionId: string; + readonly ts: Date | string; + readonly reviewerId?: string | null; + readonly userId: string; + readonly status: GQLNcmecFailedSubmissionStatus; + readonly retryCount: number; + readonly lastError?: string | null; + readonly userItemType: { + readonly __typename: 'UserItemType'; + readonly name: string; + }; +}; + export type GQLAllNcmecReportsQueryVariables = Exact<{ [key: string]: never }>; export type GQLAllNcmecReportsQuery = { @@ -18419,6 +18488,20 @@ export type GQLAllNcmecReportsQuery = { readonly ncmecFileId: string; }>; }>; + readonly failedNcmecSubmissions: ReadonlyArray<{ + readonly __typename: 'NcmecFailedSubmission'; + readonly decisionId: string; + readonly ts: Date | string; + readonly reviewerId?: string | null; + readonly userId: string; + readonly status: GQLNcmecFailedSubmissionStatus; + readonly retryCount: number; + readonly lastError?: string | null; + readonly userItemType: { + readonly __typename: 'UserItemType'; + readonly name: string; + }; + }>; readonly users: ReadonlyArray<{ readonly __typename: 'User'; readonly id: string; @@ -18476,6 +18559,19 @@ export type GQLGetNcmecReportQuery = { } | null; }; +export type GQLRetryNcmecSubmissionMutationVariables = Exact<{ + decisionId: Scalars['ID']['input']; +}>; + +export type GQLRetryNcmecSubmissionMutation = { + readonly __typename: 'Mutation'; + readonly retryNcmecSubmission: { + readonly __typename: 'RetryNcmecSubmissionResponse'; + readonly success: boolean; + readonly error?: string | null; + }; +}; + export type GQLIsWarehouseAvailableQueryVariables = Exact<{ [key: string]: never; }>; @@ -24874,6 +24970,20 @@ export const GQLNcmecReportValuesFragmentDoc = gql` isTest } `; +export const GQLNcmecFailedSubmissionValuesFragmentDoc = gql` + fragment NcmecFailedSubmissionValues on NcmecFailedSubmission { + decisionId + ts + reviewerId + userId + userItemType { + name + } + status + retryCount + lastError + } +`; export const GQLPolicyFieldsFragmentDoc = gql` fragment PolicyFields on Policy { id @@ -37095,6 +37205,9 @@ export const GQLAllNcmecReportsDocument = gql` ncmecReports { ...NCMECReportValues } + failedNcmecSubmissions { + ...NcmecFailedSubmissionValues + } users { id firstName @@ -37103,6 +37216,7 @@ export const GQLAllNcmecReportsDocument = gql` } } ${GQLNcmecReportValuesFragmentDoc} + ${GQLNcmecFailedSubmissionValuesFragmentDoc} `; /** @@ -37397,6 +37511,57 @@ export type GQLGetNcmecReportQueryResult = Apollo.QueryResult< GQLGetNcmecReportQuery, GQLGetNcmecReportQueryVariables >; +export const GQLRetryNcmecSubmissionDocument = gql` + mutation RetryNcmecSubmission($decisionId: ID!) { + retryNcmecSubmission(decisionId: $decisionId) { + success + error + } + } +`; +export type GQLRetryNcmecSubmissionMutationFn = Apollo.MutationFunction< + GQLRetryNcmecSubmissionMutation, + GQLRetryNcmecSubmissionMutationVariables +>; + +/** + * __useGQLRetryNcmecSubmissionMutation__ + * + * To run a mutation, you first call `useGQLRetryNcmecSubmissionMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useGQLRetryNcmecSubmissionMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [gqlRetryNcmecSubmissionMutation, { data, loading, error }] = useGQLRetryNcmecSubmissionMutation({ + * variables: { + * decisionId: // value for 'decisionId' + * }, + * }); + */ +export function useGQLRetryNcmecSubmissionMutation( + baseOptions?: Apollo.MutationHookOptions< + GQLRetryNcmecSubmissionMutation, + GQLRetryNcmecSubmissionMutationVariables + >, +) { + const options = { ...defaultOptions, ...baseOptions }; + return Apollo.useMutation< + GQLRetryNcmecSubmissionMutation, + GQLRetryNcmecSubmissionMutationVariables + >(GQLRetryNcmecSubmissionDocument, options); +} +export type GQLRetryNcmecSubmissionMutationHookResult = ReturnType< + typeof useGQLRetryNcmecSubmissionMutation +>; +export type GQLRetryNcmecSubmissionMutationResult = + Apollo.MutationResult; +export type GQLRetryNcmecSubmissionMutationOptions = Apollo.BaseMutationOptions< + GQLRetryNcmecSubmissionMutation, + GQLRetryNcmecSubmissionMutationVariables +>; export const GQLIsWarehouseAvailableDocument = gql` query IsWarehouseAvailable { isWarehouseAvailable @@ -43401,6 +43566,7 @@ export const namedOperations = { UpdateRoutingRule: 'UpdateRoutingRule', ReorderRoutingRules: 'ReorderRoutingRules', SetMrtChartConfigurationSettings: 'SetMrtChartConfigurationSettings', + RetryNcmecSubmission: 'RetryNcmecSubmission', AddPolicies: 'AddPolicies', UpdatePolicy: 'UpdatePolicy', DeletePolicy: 'DeletePolicy', @@ -43437,6 +43603,7 @@ export const namedOperations = { JobFields: 'JobFields', ManualReviewJobCommentFields: 'ManualReviewJobCommentFields', NCMECReportValues: 'NCMECReportValues', + NcmecFailedSubmissionValues: 'NcmecFailedSubmissionValues', PolicyFields: 'PolicyFields', RulesDashboardRuleFieldsFragment: 'RulesDashboardRuleFieldsFragment', SampleReportingRuleExecutionResultFields: diff --git a/client/src/utils/misc.ts b/client/src/utils/misc.ts index b30cd8f..b7c13ce 100644 --- a/client/src/utils/misc.ts +++ b/client/src/utils/misc.ts @@ -93,3 +93,15 @@ export type OmitRecursively = Omit< export const __throw = (x: unknown): never => { throw x; }; + +/** Used by global keyboard handlers to avoid hijacking single-key shortcuts while a form control has focus. */ +export function isTypingInEditableElement(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) { + return false; + } + const tag = target.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') { + return true; + } + return target.isContentEditable; +} diff --git a/client/src/webpages/dashboard/Dashboard.css b/client/src/webpages/dashboard/Dashboard.css index 470e1a0..32cfba0 100644 --- a/client/src/webpages/dashboard/Dashboard.css +++ b/client/src/webpages/dashboard/Dashboard.css @@ -186,3 +186,33 @@ body { -ms-overflow-style: none; /* IE and Edge */ scrollbar-width: none; /* Firefox */ } + +.scrollbar-show { + scrollbar-width: thin; /* Firefox */ + scrollbar-color: rgba(0, 0, 0, 0.25) transparent; /* Firefox */ +} + +.scrollbar-show::-webkit-scrollbar { + -webkit-appearance: none; + width: 10px; + height: 10px; +} + +.scrollbar-show::-webkit-scrollbar-track { + background: transparent; +} + +.scrollbar-show::-webkit-scrollbar-thumb { + background-color: rgba(0, 0, 0, 0.25); + border-radius: 9999px; + border: 2px solid transparent; + background-clip: content-box; +} + +.scrollbar-show::-webkit-scrollbar-thumb:hover { + background-color: rgba(0, 0, 0, 0.45); +} + +.scrollbar-show::-webkit-scrollbar-corner { + background: transparent; +} diff --git a/client/src/webpages/dashboard/Dashboard.tsx b/client/src/webpages/dashboard/Dashboard.tsx index e4336cb..be0d092 100644 --- a/client/src/webpages/dashboard/Dashboard.tsx +++ b/client/src/webpages/dashboard/Dashboard.tsx @@ -739,14 +739,16 @@ export default function Dashboard() { : '/dashboard' } > -
+ {/* min-w-0 lets descendants' overflow-x-auto scroll instead of + the whole page when content is wider than the viewport. */} +
{isCSSLoaded ? : }
) : ( -
+
-
+
diff --git a/client/src/webpages/dashboard/components/table/Table.tsx b/client/src/webpages/dashboard/components/table/Table.tsx index 17bd589..83c4aef 100644 --- a/client/src/webpages/dashboard/components/table/Table.tsx +++ b/client/src/webpages/dashboard/components/table/Table.tsx @@ -19,6 +19,9 @@ export default function Table( customMaxHeight?: `max-h-[${number}px]`; disableFilter?: boolean; containerClassName?: string; + /** Force the horizontal scrollbar to always render. Opt-in because tables + * that always fit the viewport would otherwise show an unnecessary scrollbar. */ + alwaysShowScrollbar?: boolean; } & ( | { isCollapsed?: boolean; @@ -38,6 +41,7 @@ export default function Table( customMaxHeight, disableFilter, containerClassName, + alwaysShowScrollbar, } = props; const { isCollapsed = undefined, @@ -63,7 +67,9 @@ export default function Table( }; return ( -
+
-
+
@@ -128,8 +134,8 @@ export default function Table( index === 0 ? 'rounded-tl-md' : index === headerGroup.headers.length - 1 - ? 'rounded-tr-md' - : '' + ? 'rounded-tr-md' + : '' }`} >
@@ -179,8 +185,8 @@ export default function Table( rowIndex % 2 === 0 ? 'bg-white' : 'bg-slate-50' }` : rowIndex % 2 === 0 - ? 'bg-white' - : 'bg-slate-50' + ? 'bg-white' + : 'bg-slate-50' } onClick={() => selectRow(row, rowIndex)} > @@ -205,8 +211,8 @@ export default function Table( rowIndex % 2 === 0 ? 'bg-white' : 'bg-slate-50' }` : rowIndex % 2 === 0 - ? 'bg-white' - : 'bg-slate-50' + ? 'bg-white' + : 'bg-slate-50' } onClick={() => selectRow(row, rowIndex)} > diff --git a/client/src/webpages/dashboard/mrt/manual_review_job/v2/ManualReviewJobMagnifyImageComponent.tsx b/client/src/webpages/dashboard/mrt/manual_review_job/v2/ManualReviewJobMagnifyImageComponent.tsx index 8f81394..21f0ee8 100644 --- a/client/src/webpages/dashboard/mrt/manual_review_job/v2/ManualReviewJobMagnifyImageComponent.tsx +++ b/client/src/webpages/dashboard/mrt/manual_review_job/v2/ManualReviewJobMagnifyImageComponent.tsx @@ -104,9 +104,9 @@ export default function ManualReviewJobMagnifyImageComponent(props: { footerComponent == null ) { return ( -
+
{label ? (
{label} @@ -162,11 +162,11 @@ export default function ManualReviewJobMagnifyImageComponent(props: {
} > -
+
{finalImageUrl ? ( ) : (
@@ -183,16 +183,22 @@ export default function ManualReviewJobMagnifyImageComponent(props: {
)} {label ? ( -
+
{label}
{sublabel ? ( -
{sublabel}
+
+ {sublabel} +
) : null}
) : null} diff --git a/client/src/webpages/dashboard/mrt/manual_review_job/v2/ncmec/NCMECInspectedMedia.tsx b/client/src/webpages/dashboard/mrt/manual_review_job/v2/ncmec/NCMECInspectedMedia.tsx index d07c2aa..de4283c 100644 --- a/client/src/webpages/dashboard/mrt/manual_review_job/v2/ncmec/NCMECInspectedMedia.tsx +++ b/client/src/webpages/dashboard/mrt/manual_review_job/v2/ncmec/NCMECInspectedMedia.tsx @@ -1,4 +1,5 @@ import UserAlt4 from '@/icons/lni/User/user-alt-4.svg?react'; +import { isTypingInEditableElement } from '@/utils/misc'; import type { ItemTypeFieldFieldData } from '@/webpages/dashboard/item_types/itemTypeUtils'; import { ArrowLeftOutlined, @@ -119,6 +120,9 @@ export default function NCMECInspectedMedia(props: { if (disableKeyboardShortcuts || isLabelSelectorInInspectedMediaVisible) { return; } + if (isTypingInEditableElement(event.target)) { + return; + } const currentCategory = state?.category; const newCategory = (() => { switch (event.key) { @@ -167,21 +171,24 @@ export default function NCMECInspectedMedia(props: { const navigationButtons = (
Previous
-
{`${index + 1 - } / ${totalLength}`}
+
{`${ + index + 1 + } / ${totalLength}`}
Next @@ -190,22 +197,22 @@ export default function NCMECInspectedMedia(props: { ); const threadInfoFields = threadInfo ? threadInfo.type.baseFields - .map( - (itemTypeField) => - ({ - ...itemTypeField, - value: threadInfo.data[itemTypeField.name], - }) as ItemTypeFieldFieldData, - ) - .filter((field) => { - return isContainerType(field.type) - ? !isMediaType(field.container!.valueScalarType) && - field.container!.valueScalarType !== ScalarTypes.RELATED_ITEM && - threadInfo.data[field.name] !== undefined - : !isMediaType(field.type) && - field.type !== ScalarTypes.RELATED_ITEM && - threadInfo.data[field.name] !== undefined; - }) + .map( + (itemTypeField) => + ({ + ...itemTypeField, + value: threadInfo.data[itemTypeField.name], + }) as ItemTypeFieldFieldData, + ) + .filter((field) => { + return isContainerType(field.type) + ? !isMediaType(field.container!.valueScalarType) && + field.container!.valueScalarType !== ScalarTypes.RELATED_ITEM && + threadInfo.data[field.name] !== undefined + : !isMediaType(field.type) && + field.type !== ScalarTypes.RELATED_ITEM && + threadInfo.data[field.name] !== undefined; + }) : []; const threadComponent = (() => { if (threadLoading) { @@ -319,10 +326,10 @@ export default function NCMECInspectedMedia(props: { fields={fieldData.filter((field) => { return isContainerType(field.type) ? !isMediaType(field.container!.valueScalarType) && - field.container!.valueScalarType !== - ScalarTypes.RELATED_ITEM + field.container!.valueScalarType !== + ScalarTypes.RELATED_ITEM : !isMediaType(field.type) && - field.type !== ScalarTypes.RELATED_ITEM; + field.type !== ScalarTypes.RELATED_ITEM; })} itemTypeId={fullNcmecContentItem.contentItem.type.id} /> @@ -335,11 +342,12 @@ export default function NCMECInspectedMedia(props: {
) : undefined}
User
-
+
} diff --git a/client/src/webpages/dashboard/mrt/manual_review_job/v2/ncmec/NCMECReviewUser.tsx b/client/src/webpages/dashboard/mrt/manual_review_job/v2/ncmec/NCMECReviewUser.tsx index f1487cb..c9b32d6 100644 --- a/client/src/webpages/dashboard/mrt/manual_review_job/v2/ncmec/NCMECReviewUser.tsx +++ b/client/src/webpages/dashboard/mrt/manual_review_job/v2/ncmec/NCMECReviewUser.tsx @@ -1,3 +1,4 @@ +import { isTypingInEditableElement } from '@/utils/misc'; import { BulbOutlined, ExclamationCircleOutlined } from '@ant-design/icons'; import { gql } from '@apollo/client'; import { ItemIdentifier, TaggedScalar } from '@roostorg/types'; @@ -134,9 +135,9 @@ function getUrlsFromItem( } return Array.isArray(valueOrValues) ? valueOrValues.map((it) => ({ - url: it.value.url, - mediaType: it.type, - })) + url: it.value.url, + mediaType: it.type, + })) : { url: valueOrValues.value.url, mediaType: valueOrValues.type }; }) .flat(), @@ -161,7 +162,9 @@ export function getMatchedBanksForMediaUrl( | { value: { url?: string; matchedBanks?: string[] }; type: string }[] | undefined; if (valueOrValues === undefined) continue; - const values = Array.isArray(valueOrValues) ? valueOrValues : [valueOrValues]; + const values = Array.isArray(valueOrValues) + ? valueOrValues + : [valueOrValues]; for (const tagged of values) { const v = tagged.value; if (v?.url === mediaUrl) { @@ -203,7 +206,7 @@ export default function NCMECReviewUser( payload: NCMECJobPayloadQueryResult; showMessages?: boolean; } & ( - | { + | { isActionable: false; ncmecDecisions?: readonly { readonly id: string; @@ -213,13 +216,13 @@ export default function NCMECReviewUser( readonly industryClassification: GQLNcmecIndustryClassification; }[]; } - | { + | { isActionable: true; submitDecision: (input: GQLDecisionSubmission) => Promise; skipToNextJob: () => Promise; ncmecDecisions: undefined; } - ), + ), ) { const { orgId, payload, isActionable, ncmecDecisions, showMessages } = props; const { item, allMediaItems } = payload; @@ -267,9 +270,9 @@ export default function NCMECReviewUser( : undefined; return threadId ? { - id: threadId.id, - typeId: threadId.typeId, - } + id: threadId.id, + typeId: threadId.typeId, + } : undefined; }), ), @@ -285,10 +288,10 @@ export default function NCMECReviewUser( >( allMediaItemsWithUrls.length > 0 ? { - itemId: allMediaItemsWithUrls[0].contentItem.id, - urlInfo: allMediaItemsWithUrls[0].urlInfo, - itemTypeId: allMediaItemsWithUrls[0].contentItem.type.id, - } + itemId: allMediaItemsWithUrls[0].contentItem.id, + urlInfo: allMediaItemsWithUrls[0].urlInfo, + itemTypeId: allMediaItemsWithUrls[0].contentItem.type.id, + } : undefined, ); // Selected Media = media that has been selected to be included in the @@ -296,28 +299,33 @@ export default function NCMECReviewUser( const [selectedMedia, setSelectedMedia] = useState( ncmecDecisions ? allMediaItemsWithUrls.map((media) => { - const decision = ncmecDecisions.find( - (decision) => - media.contentItem.id === decision.id && - media.contentItem.type.id === decision.typeId, - ); - if (decision) { + // Match on (id, typeId) only — media URLs in this codebase are + // signed/ephemeral and don't round-trip byte-identical across + // fetches, so adding `url` to the predicate drops every prior + // classification. Multi-media-per-item gets the first decision; + // fixing that needs a stable per-media identifier (follow-up). + const decision = ncmecDecisions.find( + (decision) => + media.contentItem.id === decision.id && + media.contentItem.type.id === decision.typeId, + ); + if (decision) { + return { + itemId: decision.id, + itemTypeId: decision.typeId, + urlInfo: media.urlInfo, + category: decision.industryClassification, + labels: [...decision.fileAnnotations], + }; + } return { - itemId: decision.id, - itemTypeId: decision.typeId, + itemId: media.contentItem.id, + itemTypeId: media.contentItem.type.id, urlInfo: media.urlInfo, - category: decision.industryClassification, - labels: [...decision.fileAnnotations], + category: 'None', + labels: [], }; - } - return { - itemId: media.contentItem.id, - itemTypeId: media.contentItem.type.id, - urlInfo: media.urlInfo, - category: 'None', - labels: [], - }; - }) + }) : [], ); const [selectedThreadsWithMessages, setSelectedThreadsWithMessages] = @@ -326,6 +334,11 @@ export default function NCMECReviewUser( GQLNcmecIncidentType.ChildPornography, ); const [escalateToHighPriority, setEscalateToHighPriority] = useState(''); + const [escalateChecked, setEscalateChecked] = useState(false); + const [additionalInfo, setAdditionalInfo] = useState(''); + const trimmedEscalate = escalateToHighPriority.trim(); + const trimmedAdditionalInfo = additionalInfo.trim(); + const escalateMissingReason = escalateChecked && trimmedEscalate === ''; const [sendReportModalVisible, setSendReportModalVisible] = useState(false); const [deselectAndIgnoreModalVisible, setDeselectAndIgnoreModalVisible] = useState(false); @@ -355,9 +368,11 @@ export default function NCMECReviewUser(
it.id).join(',')} - displayValue={`${erroredMedia.length} video${erroredMedia.length === 1 ? '' : 's' - } or image${erroredMedia.length === 1 ? '' : 's' - } failed to load. Click here to copy a list of the IDs that failed to load.`} + displayValue={`${erroredMedia.length} video${ + erroredMedia.length === 1 ? '' : 's' + } or image${ + erroredMedia.length === 1 ? '' : 's' + } failed to load. Click here to copy a list of the IDs that failed to load.`} isError={true} /> {isActionable ? ( @@ -580,22 +595,24 @@ export default function NCMECReviewUser( >
{!loading && - moderatorSafetyBlurLevel != null && - moderatorSafetyGrayscale != null ? ( + moderatorSafetyBlurLevel != null && + moderatorSafetyGrayscale != null ? ( media.urlInfo.mediaType === 'IMAGE' ? ( ) : ( setSendReportModalVisible(false)} title="Confirm & Send NCMEC Report" footer={[ { title: 'Submit Report', + disabled: escalateMissingReason, onClick: async () => { await props.submitDecision({ submitNcmecReport: { @@ -658,8 +677,11 @@ export default function NCMECReviewUser( }), reportedMessages: selectedThreadsWithMessages, incidentType, - ...(escalateToHighPriority.trim() !== '' - ? { escalateToHighPriority: escalateToHighPriority.trim() } + ...(escalateChecked && trimmedEscalate !== '' + ? { escalateToHighPriority: trimmedEscalate } + : {}), + ...(trimmedAdditionalInfo !== '' + ? { additionalInfo: trimmedAdditionalInfo } : {}), }, }); @@ -668,31 +690,26 @@ export default function NCMECReviewUser( }, ]} > -
+
-
-
-
Suspect
-
+
+
+
Suspect
+
{profilePicUrl ? ( profile pic ) : null} - {displayName ? ( -
- {displayName} ({item.id}) -
- ) : ( -
- {item.id} -
- )} +
+ {displayName ? `${displayName} (${item.id})` : item.id} +