diff --git a/HACKING.md b/HACKING.md index 0f52f9e..852a4bd 100644 --- a/HACKING.md +++ b/HACKING.md @@ -33,7 +33,7 @@ You can also test with different permission levels with the following credential - Triage: triage.test/triage-pass - Triage: admin-mod.test/admin-mod-pass -### Working with unpublished changes to the `@atproto/api` package +### Working with unpublished changes to `atproto` In the course of development there may be updates to the atproto client that are not yet published to npm, but you would like to use with Ozone. Here's the workflow for using unpublished changes to the @atproto/api package: @@ -62,23 +62,19 @@ In the course of development there may be updates to the atproto client that are ~/Documents/bluesky/atproto ❯ yarn ``` - -4. Update the package.json file in ozone/ to reference the local build of @atproto/api. - - ```diff - "dependencies": { - - "@atproto/api": "^0.0.3", - + "@atproto/api": "link:../atproto/packages/api/dist", - "@headlessui/react": "^1.7.7", - ``` - -5. Ask yarn to reinstall, creating the link from ozone/ to the local build of @atproto/api. +4. Link local atproto packages by running: ``` ~/Documents/bluesky/ozone - ❯ yarn + ❯ yarn link-atproto + ``` + This automatically patches `next.config.js`, `package.json`, clears the Next.js cache, and re-installs dependencies. If your atproto directory is not at `../atproto`, you can specify a custom path: ``` -6. Take care not to check-in the changes to package.json and yarn.lock that came from the temporary linking. When you're done, you can reset everything with: + ❯ ATPROTO_PATH=../path/to/atproto yarn link-atproto + ``` + +5. When you're done, revert back to the published packages by running: ``` ~/Documents/bluesky/ozone - ❯ git checkout package.json yarn.lock && yarn + ❯ yarn unlink-atproto ``` + Take care not to check-in any changes to `next.config.js`, `package.json`, or `yarn.lock` that came from the temporary linking. diff --git a/app/actions/ModActionPanel/QuickAction.tsx b/app/actions/ModActionPanel/QuickAction.tsx index 94b426a..a1e3f88 100644 --- a/app/actions/ModActionPanel/QuickAction.tsx +++ b/app/actions/ModActionPanel/QuickAction.tsx @@ -280,7 +280,6 @@ function Form( isAuthorTakendown={ !!record?.repo.moderation.subjectStatus?.takendown } - className="border-2 border-dashed border-gray-300" > {!isSubjectDid && record?.repo && (
diff --git a/app/actions/ModActionPanel/useQuickAction.tsx b/app/actions/ModActionPanel/useQuickAction.tsx index 40134e1..bdf7c0c 100644 --- a/app/actions/ModActionPanel/useQuickAction.tsx +++ b/app/actions/ModActionPanel/useQuickAction.tsx @@ -594,7 +594,7 @@ export const useQuickAction = ( refetchSubjectStatus() refetchSubject() - queryClient.invalidateQueries(['modEventList']) + queryClient.invalidateQueries({ queryKey: ['modEventList'] }) // After successful submission, reset the form state to clear inputs for previous submission ev.target.reset() @@ -651,7 +651,7 @@ export const useQuickAction = ( createdBy: accountDid, }) // email event does not change the subject status so only need to refetch mod event list - queryClient.invalidateQueries(['modEventList']) + queryClient.invalidateQueries({ queryKey: ['modEventList'] }) setModEventType(MOD_EVENTS.ACKNOWLEDGE) setSubmission({ isSubmitting: false, error: '' }) } catch (err) { diff --git a/app/analytics/detail/page-content.tsx b/app/analytics/detail/page-content.tsx new file mode 100644 index 0000000..145476e --- /dev/null +++ b/app/analytics/detail/page-content.tsx @@ -0,0 +1,77 @@ +'use client' + +import { groupedReasonTypes } from '@/reports/helpers/getType' +import { HistoricalGraph } from '@/reports/stats/HistoricalGraph' +import { LiveStatsPanel } from '@/reports/stats/LiveStats' +import { + StatsFilters, + useParamStatsFilters, +} from '@/reports/stats/StatsFilters' +import { + HistoricalStatsParams, + LiveStatsParams, + useHistoricalStats, +} from '@/reports/stats/useReportStats' +import { ArrowLeftIcon } from '@heroicons/react/24/solid' +import Link from 'next/link' + +export function StatsDetailPageContent() { + const { filters, handleFilterChange } = useParamStatsFilters() + + const isAggregate = filters.grouping === 'aggregate' + const reportTypes = filters.category + ? groupedReasonTypes[filters.category] + : [] + const live: LiveStatsParams = isAggregate + ? {} + : { + reportTypes, + queueId: filters.queueId, + moderatorDid: filters.moderatorDid, + } + const historical: HistoricalStatsParams = isAggregate + ? { + startDate: filters.dateRange.startDate, + endDate: filters.dateRange.endDate, + } + : { + reportTypes, + queueId: filters.queueId, + moderatorDid: filters.moderatorDid, + startDate: filters.dateRange.startDate, + endDate: filters.dateRange.endDate, + } + + const { + data: historicalStats, + isLoading: histLoading, + isError: histError, + refetch: histRefetch, + } = useHistoricalStats(historical) + + return ( +
+
+ + + +

+ Analytics +

+
+ + + + + +
+ histRefetch()} + /> +
+
+ ) +} diff --git a/app/analytics/detail/page.tsx b/app/analytics/detail/page.tsx new file mode 100644 index 0000000..565e1fc --- /dev/null +++ b/app/analytics/detail/page.tsx @@ -0,0 +1,11 @@ +'use client' +import { Suspense } from 'react' +import { StatsDetailPageContent } from './page-content' + +export default function StatsDetailPage() { + return ( +
}> + + + ) +} diff --git a/app/analytics/page-content.tsx b/app/analytics/page-content.tsx new file mode 100644 index 0000000..b8f5f59 --- /dev/null +++ b/app/analytics/page-content.tsx @@ -0,0 +1,63 @@ +'use client' +import { PaginatedGrid } from '@/common/PaginatedGrid' +import { useQueueList } from '@/queues/useQueues' +import { REPORT_CATEGORIES } from '@/reports/stats' +import { LiveStatsCards } from '@/reports/stats/LiveStats' +import { StatsCard } from '@/reports/stats/Stats' +import { useMemo } from 'react' +import { useTitle } from 'react-use' + +export function AnalyticsPageContent() { + useTitle('Analytics') + + const { data: queuesData, hasNextPage, fetchNextPage } = useQueueList() + const queues = useMemo( + () => queuesData?.pages?.flatMap((page) => page.queues) ?? [], + [queuesData], + ) + + return ( +
+

+ Analytics +

+ +
+ +
+ + {queues.length > 0 && ( +
+

+ Queues +

+ ( + + )} + /> +
+ )} + +
+

+ Categories +

+
+ {REPORT_CATEGORIES.map((group) => ( + + ))} +
+
+
+ ) +} diff --git a/app/analytics/page.tsx b/app/analytics/page.tsx new file mode 100644 index 0000000..0326cbd --- /dev/null +++ b/app/analytics/page.tsx @@ -0,0 +1,11 @@ +'use client' +import { Suspense } from 'react' +import { AnalyticsPageContent } from './page-content' + +export default function AnalyticsPage() { + return ( + }> + + + ) +} diff --git a/app/configure/page-content.tsx b/app/configure/page-content.tsx index 5d509d1..3ff3d72 100644 --- a/app/configure/page-content.tsx +++ b/app/configure/page-content.tsx @@ -18,6 +18,7 @@ import { SetsConfig } from '@/config/Sets' import { ProtectedTagsConfig } from '@/config/ProtectedTags' import { PoliciesConfig } from '@/config/Policies' import { SafelinkConfig } from '@/config/Safelink' +import { QueuesConfig } from '@/queues/configure/index' enum Views { Configure, @@ -26,6 +27,7 @@ enum Views { Safelink, ProtectedTags, Policies, + Queues, } const TabKeys = { @@ -35,6 +37,7 @@ const TabKeys = { safelink: Views.Safelink, protectedTags: Views.ProtectedTags, policies: Views.Policies, + queues: Views.Queues, } export default function ConfigurePageContent() { @@ -95,6 +98,10 @@ export default function ConfigurePageContent() { view: Views.ProtectedTags, label: 'Protected Tags', }, + { + view: Views.Queues, + label: 'Queues', + }, ] return ( @@ -111,6 +118,7 @@ export default function ConfigurePageContent() { {currentView === Views.Safelink && } {currentView === Views.ProtectedTags && } {currentView === Views.Policies && } + {currentView === Views.Queues && } page.queues) ?? [] + + return ( +
+ {/* Header */} +
+
+

+ Queues +

+
+ + + + Analytics + + + + + + Configure + + +
+
+
+ + {/* Stats */} +
+ +
+ + {/* Queue list */} +
+ {isError && ( +
+ Failed to load queues.{' '} + +
+ )} + +
+
+ ) +} diff --git a/app/queues/page.tsx b/app/queues/page.tsx new file mode 100644 index 0000000..f1e1bb7 --- /dev/null +++ b/app/queues/page.tsx @@ -0,0 +1,11 @@ +'use client' +import { Suspense } from 'react' +import { QueuesPageContent } from './page-content' + +export default function QueuesPage() { + return ( + }> + + + ) +} diff --git a/app/reports/[id]/page-content.tsx b/app/reports/[id]/page-content.tsx new file mode 100644 index 0000000..bc7b4be --- /dev/null +++ b/app/reports/[id]/page-content.tsx @@ -0,0 +1,1065 @@ +'use client' +import { useState, useEffect, useMemo } from 'react' +import { useParams, useRouter, useSearchParams, usePathname } from 'next/navigation' +import { useQuery, useQueryClient, InfiniteData } from '@tanstack/react-query' +import { + ToolsOzoneReportDefs, + ToolsOzoneModerationEmitEvent, + ComAtprotoModerationDefs, +} from '@atproto/api' +import { formatDistanceToNow } from 'date-fns' +import { toast } from 'react-toastify' +import { + ArrowLeftIcon, + ChevronLeftIcon, + ChevronRightIcon, +} from '@heroicons/react/24/outline' +import { ActionButton, ButtonPrimary, ButtonSecondary } from '@/common/buttons' +import { Checkbox, FormLabel, Select, Textarea } from '@/common/forms' +import { PreviewCard } from '@/common/PreviewCard' +import { ModEventList } from '@/mod-event/EventList' +import { ModEventItem } from '@/mod-event/EventItem' +import { ModToolProvider } from '@/mod-event/ModToolContext' +import { + LabelList, + LabelListEmpty, + ModerationLabel, +} from '@/common/labels/List' +import { isSelfLabel } from '@/common/labels/util' +import { LabelSelector } from '@/common/labels/Selector' +import { getDidFromUri } from '@/lib/util' +import { Loading } from '@/common/Loader' +import { BlobListFormField } from 'app/actions/ModActionPanel/BlobList' +import { ActionDurationSelector } from '@/reports/ModerationForm/ActionDurationSelector' +import { + ReviewStateIcon, + SubjectReviewStateBadge, +} from '@/subject/ReviewStateMarker' +import { ActionError } from '@/reports/ModerationForm/ActionError' +import { MessageActorMeta } from '@/dms/MessageActorMeta' +import { ModEventDetailsPopover } from '@/mod-event/DetailsPopover' +import { LastReviewedTimestamp } from '@/subject/LastReviewedTimestamp' +import { RecordAuthorStatus } from '@/subject/RecordAuthorStatus' +import { SubjectTag } from 'components/tags/SubjectTag' +import { HighProfileWarning } from '@/repositories/HighProfileWarning' +import { PriorityScore } from '@/subject/PriorityScore' +import { Alert } from '@/common/Alert' +import { TextWithLinks } from '@/common/TextWithLinks' +import { AgeAssuranceBadge } from '@/mod-event/AgeAssuranceStateBadge' +import { useQuickAction } from 'app/actions/ModActionPanel/useQuickAction' +import { PolicySeveritySelector } from 'app/actions/ModActionPanel/PolicySeveritySelector' +import { EmailComposerFields } from 'components/email/Composer' +import { + ActionPanelNames, + hydrateModToolInfo, + useEmitEvent, +} from '@/mod-event/helpers/emitEvent' +import { useLabelerAgent } from '@/shell/ConfigurationContext' +import { ReasonBadge } from 'components/reports/ReasonBadge' +import { useAssignModerator, useUnassignModerator } from 'components/reports/hooks' +import { ConfirmationModal } from '@/common/modals/confirmation' +import { + ReportActionsBar, + ActivityTimeline, + ReportActionType, +} from 'components/reports/ReportActions' +import { MemberView } from 'components/reports/MemberView' +import { ReportTypeMultiselect } from '@/reports/ReportTypeMultiselect' +import { MOD_EVENTS } from '@/mod-event/constants' +import { ReportStatusBadge } from 'components/reports/ReportStatusBadge' +import { MutedBadge } from 'components/reports/MutedBadge' +import { QueueBadge } from 'components/reports/QueueBadge' +import { + ViewersIndicator, + AssignmentViewWithModerator, +} from 'components/reports/ViewersIndicator' +import { getHandleFromSubjectView } from 'components/reports/utils' +import { useAssignmentPolling } from 'components/reports/useAssignmentPolling' +import { ModActionPanelQuick } from 'app/actions/ModActionPanel/QuickAction' +import { WorkspacePanel } from 'components/workspace/Panel' +import { useWorkspaceOpener } from '@/common/useWorkspaceOpener' + +const FORM_ID = 'report-detail-action-panel' + +const REPORT_STATUS_EVENT_TYPES = new Set([ + MOD_EVENTS.ACKNOWLEDGE, + MOD_EVENTS.TAKEDOWN, + MOD_EVENTS.LABEL, + MOD_EVENTS.COMMENT, + MOD_EVENTS.ESCALATE, + MOD_EVENTS.REVERSE_TAKEDOWN, + MOD_EVENTS.RESOLVE_APPEAL, +]) + +function isAppealReport(reportType?: string): boolean { + return ( + reportType === ComAtprotoModerationDefs.REASONAPPEAL || + reportType === 'tools.ozone.report.defs#reasonAppeal' + ) +} + +function getReportsFromCache( + queryClient: ReturnType, +): ToolsOzoneReportDefs.ReportView[] { + const allReports: ToolsOzoneReportDefs.ReportView[] = [] + for (const key of ['events', 'betaReports']) { + const allQueriesData = queryClient.getQueriesData< + InfiniteData<{ reports: ToolsOzoneReportDefs.ReportView[] }> + >({ queryKey: [key] }) + + for (const [, data] of allQueriesData) { + if (!data?.pages) continue + for (const page of data.pages) { + if (page.reports) allReports.push(...page.reports) + } + } + } + return allReports +} + +function findAdjacentReportsInCache( + queryClient: ReturnType, + reportId: number, +): { prevId: number | null; nextId: number | null } { + const allReports = getReportsFromCache(queryClient) + + if (allReports.length === 0) return { prevId: null, nextId: null } + + const index = allReports.findIndex((r) => r.id === reportId) + if (index === -1) return { prevId: null, nextId: null } + + return { + prevId: index > 0 ? allReports[index - 1].id : null, + nextId: index < allReports.length - 1 ? allReports[index + 1].id : null, + } +} + +function findReportInCache( + queryClient: ReturnType, + reportId: number, +): ToolsOzoneReportDefs.ReportView | null { + const allReports = getReportsFromCache(queryClient) + return allReports.find((r) => r.id === reportId) ?? null +} + +function ReportInfoPanel({ + report, + assignment, + onClickDid, +}: { + report: ToolsOzoneReportDefs.ReportView + assignment?: ToolsOzoneReportDefs.ReportAssignment + onClickDid?: (did: string) => void +}) { + const labelerAgent = useLabelerAgent() + const [unassignConfirmOpen, setUnassignConfirmOpen] = useState(false) + + const { + mutate: assignToMe, + isPending, + error, + } = useAssignModerator({ + onSuccess: () => toast.success('Report assigned to you'), + }) + + const { + mutate: unassignFromMe, + isPending: isUnassigning, + error: unassignError, + } = useUnassignModerator({ + onSuccess: () => { + toast.success('You have been unassigned from this report') + setUnassignConfirmOpen(false) + }, + }) + + const reporterHandle = getHandleFromSubjectView(report.reporter) + const createdAt = new Date(report.createdAt) + + const moderator = assignment?.moderator + const isAssignedToMe = !!assignment && assignment.did === labelerAgent.did + + return ( +
+ {/* Report metadata */} +
+ + + + + + {formatDistanceToNow(createdAt, { addSuffix: true })} + +
+ + {/* Reporter */} +
+ + Reporter: + + {report.reporter.status && ( + + )} + {onClickDid ? ( + + ) : ( + + {reporterHandle ? `@${reporterHandle}` : report.reporter.subject} + + )} +
+ + {/* Comment */} + {!!report.comment && ( +
+

+ Comment +

+ +
+ )} + + {/* Assignment */} +
+ {assignment ? ( +
+
+ {moderator ? ( + + ) : ( +
+ Assigned to{' '} + {onClickDid ? ( + + ) : ( + assignment.did + )} +
+ )} +
+ {isAssignedToMe && ( + setUnassignConfirmOpen(true)} + > + Unassign + + )} +
+ ) : ( +
+ assignToMe(report.id)} + > + {isPending ? 'Assigning…' : 'Assign to me'} + + {!!error && ( + + {(error as any)?.message ?? 'Failed to assign'} + + )} +
+ )} +
+ + + Are you sure you want to unassign yourself from this report? You + will no longer be the moderator handling it. + + } + confirmButtonText={isUnassigning ? 'Unassigning…' : 'Unassign'} + confirmButtonDisabled={isUnassigning} + error={ + unassignError + ? (unassignError as any)?.message ?? 'Failed to unassign' + : undefined + } + onConfirm={() => unassignFromMe(report.id)} + /> +
+ ) +} + +export function ReportDetailPageContent() { + const params = useParams() + const router = useRouter() + const searchParams = useSearchParams() + const pathname = usePathname() + const reportId = Number(params.id) + const labelerAgent = useLabelerAgent() + const queryClient = useQueryClient() + const emitEvent = useEmitEvent() + const { toggleWorkspacePanel, isWorkspaceOpen } = useWorkspaceOpener() + + const quickOpenParam = searchParams.get('quickOpen') ?? '' + const setQuickActionPanelSubject = (subject: string) => { + const nextParams = new URLSearchParams(searchParams) + if (!subject) { + nextParams.delete('quickOpen') + } else { + nextParams.set('quickOpen', subject) + } + router.push((pathname ?? '') + '?' + nextParams.toString()) + } + + const cachedReport = useMemo( + () => findReportInCache(queryClient, reportId), + [reportId], + ) + + const { prevId, nextId } = useMemo( + () => findAdjacentReportsInCache(queryClient, reportId), + [reportId], + ) + + // Register this session as a viewer (temporary, non-permanent assignment). + useEffect(() => { + labelerAgent.tools.ozone.report + .assignModerator({ reportId }) + .catch(() => {}) + }, [reportId]) + + // If no cached report exists, getReport will be called and returns fresh assignment data. + // In that case we skip the initial assignment poll and start it after the first interval. + const calledGetReport = !cachedReport + + const { data: report, isLoading } = useQuery({ + queryKey: ['report', reportId], + queryFn: async () => { + const { data } = await labelerAgent.tools.ozone.report.getReport({ + id: reportId, + }) + return data + }, + initialData: cachedReport ?? undefined, + }) + + const [subject, setSubject] = useState(report?.subject.subject ?? '') + + const reportSubject = report?.subject.subject + useEffect(() => { + if (reportSubject && !subject) { + setSubject(reportSubject) + } + }, [reportSubject, subject]) + + const { viewers } = useAssignmentPolling({ + reportId, + hasReport: !!report, + initialAssigneeDid: report?.assignment?.did, + skipInitialPoll: calledGetReport, + }) + + if (!report && isLoading) { + return ( +
+ +

+ Loading report... +

+
+ ) + } + + if (!report && !isLoading) { + return ( +
+

Report not found.

+
+ ) + } + + return ( +
+
+ { + const savedUrl = sessionStorage.getItem('ozone:reportsListUrl') + router.push(savedUrl ?? '/reports/beta') + }} + > + + Reports + +

+ Report #{reportId} +

+ {(prevId !== null || nextId !== null) && ( +
+ + prevId !== null && router.push(`/reports/${prevId}`) + } + > + + Prev + + + nextId !== null && router.push(`/reports/${nextId}`) + } + > + Next + + +
+ )} +
+ + {subject && report && ( + { + await emitEvent( + hydrateModToolInfo(vals, ActionPanelNames.QuickAction), + ) + queryClient.invalidateQueries({ queryKey: ['report', reportId] }) + queryClient.invalidateQueries({ queryKey: ['reportActivities', reportId] }) + }} + onCancel={() => router.push('/reports')} + onClickDid={setQuickActionPanelSubject} + /> + )} + setQuickActionPanelSubject('')} + setSubject={setQuickActionPanelSubject} + subject={quickOpenParam} + subjectOptions={subject ? [subject] : [quickOpenParam]} + isInitialLoading={isLoading} + onSubmit={async (vals: ToolsOzoneModerationEmitEvent.InputSchema) => { + await emitEvent( + hydrateModToolInfo(vals, ActionPanelNames.QuickAction), + ) + queryClient.invalidateQueries({ queryKey: ['report', reportId] }) + queryClient.invalidateQueries({ queryKey: ['reportActivities', reportId] }) + }} + /> + toggleWorkspacePanel()} + /> +
+ ) +} + +function ReportDetailLayout(props: { + report: ToolsOzoneReportDefs.ReportView + subject: string + setSubject: (subject: string) => void + onSubmit: (vals: ToolsOzoneModerationEmitEvent.InputSchema) => Promise + onCancel: () => void + assignment?: ToolsOzoneReportDefs.ReportAssignment + viewers: AssignmentViewWithModerator[] + onClickDid?: (did: string) => void +}) { + const { + report, + subject, + setSubject, + onSubmit, + onCancel, + assignment, + viewers, + onClickDid, + } = props + const subjectOptions = [subject] + + const [reportActionScope, setReportActionScope] = useState< + 'current' | 'all' | 'types' + >('current') + const [reportActionTypes, setReportActionTypes] = useState(() => { + // If the queue has report types, default to those + if (report.queue?.reportTypes && report.queue.reportTypes.length > 0) { + return report.queue.reportTypes + } + // Otherwise, fall back to the current report's type + return report.reportType ? [report.reportType] : [] + }) + const [selectedAction, setSelectedAction] = useState(null) + const [applyToAccount, setApplyToAccount] = useState(false) + const isSubjectRecord = subject.startsWith('at://') + + const wrappedOnSubmit = async ( + vals: ToolsOzoneModerationEmitEvent.InputSchema, + ) => { + let finalVals = vals + + // When "Apply action to account" is checked, override the subject to be account-level + if (applyToAccount && isSubjectRecord) { + const did = getDidFromUri(subject) + if (did) { + finalVals = { + ...vals, + subject: { + $type: 'com.atproto.admin.defs#repoRef', + did, + }, + } + } + } + + const eventType = (finalVals.event as any)?.$type as string | undefined + if (eventType && REPORT_STATUS_EVENT_TYPES.has(eventType as any)) { + const reportAction: ToolsOzoneModerationEmitEvent.ReportAction = {} + if (reportActionScope === 'current') { + reportAction.ids = [report.id] + } else if (reportActionScope === 'all') { + reportAction.all = true + } else if (reportActionTypes.length > 0) { + reportAction.types = reportActionTypes + } + await onSubmit({ ...finalVals, reportAction }) + + // For appeal reports: emit resolveAppeal after the primary action (revert takedown or label) + if ( + isAppealReport(report.reportType) && + eventType !== MOD_EVENTS.RESOLVE_APPEAL + ) { + await onSubmit({ + ...finalVals, + event: { + $type: MOD_EVENTS.RESOLVE_APPEAL, + comment: '[RESOLVING_APPEAL]', + }, + reportAction: { ids: [report.id] }, + }) + } + + setSelectedAction(null) // Reset after successful submission + } else { + await onSubmit(finalVals) + } + } + + const handleCancelAction = () => { + setSelectedAction(null) + } + + // Sync selectedAction with modEventType + useEffect(() => { + if (selectedAction === 'label') { + setModEventType(MOD_EVENTS.LABEL) + } else if (selectedAction === 'takedown') { + setModEventType(MOD_EVENTS.TAKEDOWN) + } else if (selectedAction === 'revert-takedown') { + setModEventType(MOD_EVENTS.REVERSE_TAKEDOWN) + } + }, [selectedAction]) + + const { + submission, + onFormSubmit, + record, + isSubjectDid, + profile, + subjectStatus, + selectedSeverityLevelName, + canManageChat, + currentLabels, + allLabels, + repo, + modEventType, + shouldShowDurationInHoursField, + isLabelEvent, + isTakedownEvent, + isReverseTakedownEvent, + setModEventType, + policyDetails, + strikeData, + strikeDataError, + currentStrikes, + actionRecommendation, + severityLevelStrikeCount, + selectedPolicyName, + durationSelectorRef, + submitButton, + handlePolicySelect, + handleSeverityLevelSelect, + targetServices, + setTargetServices, + config, + showAutomatedEmailComposer, + showCantEmailError, + automatedEmailTemplate, + communicationTemplates, + theme, + recipientLanguages, + emailContent, + setEmailContent, + onEmailTemplateSelect, + emailSubjectField, + } = useQuickAction({ + onCancel, + onSubmit: wrappedOnSubmit, + subject, + setSubject, + subjectOptions, + }) + + const showReportAction = (REPORT_STATUS_EVENT_TYPES as Set).has( + modEventType, + ) + + return ( +
+
+ {/* Left col */} +
+ {/* Subject preview */} +
+ + {!isSubjectDid && record?.repo && ( +
+ +
+ )} +
+
+ + {/* Subject status */} + {!!subjectStatus && ( +
+

+ {!!subjectStatus.priorityScore && ( + + )} + {!!subjectStatus.ageAssuranceState && + subjectStatus.ageAssuranceState !== 'unknown' && ( + + )} + + +

+ {!!subjectStatus.comment && ( +
+ } + /> +
+ )} + {!!record?.repo.moderation.subjectStatus?.comment && ( +
+ + } + /> +
+ )} +
+ )} + + {/* Blobs */} + {!!record?.blobs?.length && ( + + )} + + {/* DM meta */} + {isSubjectDid && canManageChat && ( +
+ +
+ )} + + {/* Labels */} +
+ + + {!currentLabels.length && } + {allLabels.map((label) => ( + + ))} + + +
+ + {/* Subject tags */} + {!!subjectStatus?.tags?.length && ( +
+ + + {subjectStatus.tags.sort().map((tag) => ( + + ))} + + +
+ )} + + {/* Account labels (records only) */} + {!isSubjectDid && !!profile?.labels?.length && ( +
+
Account Labels
+ {profile.labels.map((label) => ( + + ))} +
+ )} + + {/* Account tags (records only) */} + {!!record?.repo.moderation.subjectStatus?.tags?.length && ( +
+
Account Tags
+ + {record.repo.moderation.subjectStatus.tags.sort().map((tag) => ( + + ))} + +
+ )} + + {/* Event stream */} +
+ +
+
+ + {/* Right col */} +
+ + + + + {profile && ( +
+ +
+ )} + + {/* Strike data error - unlikely to happen */} + {!!strikeDataError && (isTakedownEvent || isReverseTakedownEvent) && ( +
+ + Please be cautious when taking actions that require + up-to-date strike info.{' '} + {' '} + to reload strike data for this account. + + } + /> +
+ )} + + { + const reportAction: ToolsOzoneModerationEmitEvent.ReportAction = { ids: [report.id] } + await onSubmit({ + subject: { $type: 'com.atproto.admin.defs#repoRef', did: subject.startsWith('at://') ? getDidFromUri(subject)! : subject }, + createdBy: config.did, + event: { + $type: MOD_EVENTS.RESOLVE_APPEAL, + comment: '[RESOLVING_APPEAL]', + }, + reportAction, + }) + } + : undefined + } + /> + +
+
+
+ + {selectedAction === 'label' + ? 'Label' + : selectedAction === 'takedown' + ? 'Takedown' + : selectedAction === 'revert-takedown' + ? 'Revert Takedown' + : 'Action'} + + +
+ + {isSubjectRecord && ( + setApplyToAccount(e.target.checked)} + label="Apply action to account" + /> + )} + + {(isTakedownEvent || isReverseTakedownEvent) && ( + + )} + + {shouldShowDurationInHoursField && ( +
+ + + +
+ )} + + {isLabelEvent && ( +
+ { + const isEditableLabel = allLabels.some( + (l) => l.val === label && l.src === config.did, + ) + return !isSelfLabel(label) && isEditableLabel + })} + /> +
+ )} + +
+