From ae2fdb09e13de8e6f79c6928b7c262a5f7d59db1 Mon Sep 17 00:00:00 2001 From: Foysal Ahamed Date: Fri, 3 Jan 2025 22:35:19 +0000 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Takedown=20policy=20association=20(?= =?UTF-8?q?#261)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * :sparkles: Allow associating policies with takedowns * :sparkles: Add policy manager area * :sparkles: Add policy filter to events tab * :arrow_up: Update @atproto/ozone and @atproto/api version * :sparkles: Add takedown policy association with bulk takedown * :sparkles: Replace react hook based pathname with browser api --- app/actions/ModActionPanel/QuickAction.tsx | 48 ++++- app/configure/page-content.tsx | 9 +- components/config/Policies.tsx | 101 ++++++++++ components/mod-event/EventItem.tsx | 31 ++- components/mod-event/EventList.tsx | 2 + components/mod-event/FilterPanel.tsx | 15 ++ components/mod-event/useModEventList.tsx | 8 + .../reports/ModerationForm/ActionError.tsx | 6 +- .../ModerationForm/ActionPolicySelector.tsx | 190 ++++++++++++++++++ components/setting/policy/Editor.tsx | 69 +++++++ components/setting/policy/List.tsx | 95 +++++++++ components/setting/policy/types.ts | 6 + components/setting/policy/usePolicyList.tsx | 96 +++++++++ components/setting/policy/utils.ts | 9 + components/setting/useQueueSetting.ts | 2 +- components/shell/CommandPalette/Root.tsx | 4 +- components/shell/CommandPalette/actions.ts | 16 +- components/workspace/Panel.tsx | 37 +++- components/workspace/PanelActionForm.tsx | 34 ++-- package.json | 2 +- service/package.json | 2 +- service/yarn.lock | 18 +- tsconfig.json | 1 + yarn.lock | 8 +- 24 files changed, 746 insertions(+), 63 deletions(-) create mode 100644 components/config/Policies.tsx create mode 100644 components/reports/ModerationForm/ActionPolicySelector.tsx create mode 100644 components/setting/policy/Editor.tsx create mode 100644 components/setting/policy/List.tsx create mode 100644 components/setting/policy/types.ts create mode 100644 components/setting/policy/usePolicyList.tsx create mode 100644 components/setting/policy/utils.ts diff --git a/app/actions/ModActionPanel/QuickAction.tsx b/app/actions/ModActionPanel/QuickAction.tsx index 45974014..ee704ecf 100644 --- a/app/actions/ModActionPanel/QuickAction.tsx +++ b/app/actions/ModActionPanel/QuickAction.tsx @@ -59,6 +59,7 @@ import { import { SubjectTag } from 'components/tags/SubjectTag' import { HighProfileWarning } from '@/repositories/HighProfileWarning' import { EmailComposer } from 'components/email/Composer' +import { ActionPolicySelector } from '@/reports/ModerationForm/ActionPolicySelector' const FORM_ID = 'mod-action-panel' const useBreakpoint = createBreakpoint({ xs: 340, sm: 640 }) @@ -266,6 +267,10 @@ function Form( coreEvent.durationInHours = Number(formData.get('durationInHours')) } + if (isTakedownEvent && formData.get('policies')) { + coreEvent.policies = [String(formData.get('policies'))] + } + if ( (isTakedownEvent || isAckEvent) && formData.get('acknowledgeAccountSubjects') @@ -333,6 +338,10 @@ function Form( throw new Error('blob-selection-required') } + if (isTakedownEvent && !coreEvent.policies) { + throw new Error('policy-selection-required') + } + // This block handles an edge case where a label may be applied to profile record and then the profile record is updated by the user. // In that state, if the moderator reverts the label, the event is emitted for the latest CID of the profile entry which does NOT revert // the label applied to the old CID. @@ -666,16 +675,35 @@ function Form( {shouldShowDurationInHoursField && ( - - - +
+ + { + if (e.target.value === '0') { + // When permanent takedown is selected, auto check ack all checkbox + const ackAllCheckbox = + document.querySelector( + 'input[name="acknowledgeAccountSubjects"]', + ) + if (ackAllCheckbox && !ackAllCheckbox.checked) { + ackAllCheckbox.checked = true + } + } + }} + labelText={isMuteEvent ? 'Mute duration' : ''} + /> + + {isTakedownEvent && ( +
+ +
+ )} +
)} {isMuteReporterEvent && ( diff --git a/app/configure/page-content.tsx b/app/configure/page-content.tsx index 218ecbad..c4b8e7b5 100644 --- a/app/configure/page-content.tsx +++ b/app/configure/page-content.tsx @@ -12,12 +12,14 @@ import { WorkspacePanel } from '@/workspace/Panel' import { useWorkspaceOpener } from '@/common/useWorkspaceOpener' import { SetsConfig } from '@/config/Sets' import { ProtectedTagsConfig } from '@/config/ProtectedTags' +import { PoliciesConfig } from '@/config/Policies' enum Views { Configure, Members, Sets, ProtectedTags, + Policies, } const TabKeys = { @@ -25,6 +27,7 @@ const TabKeys = { members: Views.Members, sets: Views.Sets, protectedTags: Views.ProtectedTags, + policies: Views.Policies, } export default function ConfigurePageContent() { @@ -73,6 +76,10 @@ export default function ConfigurePageContent() { view: Views.Sets, label: 'Sets', }, + { + view: Views.Policies, + label: 'Policies', + }, { view: Views.ProtectedTags, label: 'Protected Tags', @@ -90,8 +97,8 @@ export default function ConfigurePageContent() { {currentView === Views.Configure && } {currentView === Views.Members && } {currentView === Views.Sets && } - {currentView === Views.Sets && } {currentView === Views.ProtectedTags && } + {currentView === Views.Policies && } +
+ {typeof searchQuery === 'string' ? ( + <> + { + const url = createPolicyPageLink({ search: e.target.value }) + router.push(url) + }} + />{' '} + + Cancel + + + ) : ( + <> +
+

+ Manage Policies +

+
+ {!showPoliciesCreateForm && ( +
+ {canManagePolicies && ( + + + Add New Policy + + )} + + + + +
+ )} + + )} +
+ {showPoliciesCreateForm && ( +
+ { + const url = createPolicyPageLink({}) + router.push(url) + }} + onSuccess={() => { + const url = createPolicyPageLink({}) + router.push(url) + }} + /> +
+ )} + + + + ) +} diff --git a/components/mod-event/EventItem.tsx b/components/mod-event/EventItem.tsx index dce7bdd2..e3f649bf 100644 --- a/components/mod-event/EventItem.tsx +++ b/components/mod-event/EventItem.tsx @@ -12,6 +12,9 @@ import { useConfigurationContext } from '@/shell/ConfigurationContext' import { ItemTitle } from './ItemTitle' import { PreviewCard } from '@/common/PreviewCard' import { ModEventViewWithDetails } from './useModEventList' +import { ClockIcon, DocumentTextIcon } from '@heroicons/react/24/solid' +import Link from 'next/link' +import { pluralize } from '@/lib/util' const LinkToAuthor = ({ creatorHandle, @@ -219,8 +222,34 @@ const TakedownOrMute = ({ {expiresAt && ( -

Until {dateFormatter.format(expiresAt)}

+

+ + Until {dateFormatter.format(expiresAt)} +

)} + {ToolsOzoneModerationDefs.isModEventTakedown(modEvent.event) && + modEvent.event.policies?.length ? ( +

+ + + Under{' '} + {modEvent.event.policies.map((policy) => { + return ( + + {`${policy}`}{' '} + + ) + })} + {pluralize(modEvent.event.policies.length, 'policy', { + plural: 'policies', + })} + +

+ ) : null} {modEvent.event.comment ? (

{`${modEvent.event.comment}`}

) : null} diff --git a/components/mod-event/EventList.tsx b/components/mod-event/EventList.tsx index 5e26eea7..3128e1c3 100644 --- a/components/mod-event/EventList.tsx +++ b/components/mod-event/EventList.tsx @@ -143,6 +143,7 @@ export const ModEventList = ( isInitialLoadingModEvents, hasFilter, commentFilter, + policies, toggleCommentFilter, setCommentFilterKeyword, createdBy, @@ -285,6 +286,7 @@ export const ModEventList = ( removedTags, applyFilterMacro, changeListFilter, + policies, }} /> )} diff --git a/components/mod-event/FilterPanel.tsx b/components/mod-event/FilterPanel.tsx index 076982ca..6ab3d086 100644 --- a/components/mod-event/FilterPanel.tsx +++ b/components/mod-event/FilterPanel.tsx @@ -17,6 +17,10 @@ import { useState } from 'react' import { RepoFinder } from '@/repositories/Finder' import { Dropdown } from '@/common/Dropdown' import { ChevronDownIcon } from '@heroicons/react/24/solid' +import { + ActionPoliciesSelector, + ActionPolicySelector, +} from '@/reports/ModerationForm/ActionPolicySelector' export const EventFilterPanel = ({ limit, @@ -271,6 +275,17 @@ export const EventFilterPanel = ({ + {types.includes(MOD_EVENTS.TAKEDOWN) && ( +
+ + { + changeListFilter({ field: 'policies', value: policies }) + }} + /> + +
+ )} {types.includes(MOD_EVENTS.TAG) && (
diff --git a/components/mod-event/useModEventList.tsx b/components/mod-event/useModEventList.tsx index 76f9370f..145aa45c 100644 --- a/components/mod-event/useModEventList.tsx +++ b/components/mod-event/useModEventList.tsx @@ -62,6 +62,7 @@ const initialListState = { removedLabels: [], addedTags: '', removedTags: '', + policies: [], showContentPreview: false, limit: 25, } @@ -157,6 +158,7 @@ type EventListFilterPayload = | { field: 'removedLabels'; value: string[] } | { field: 'addedTags'; value: string } | { field: 'removedTags'; value: string } + | { field: 'policies'; value: string[] } | { field: 'limit'; value: number } type EventListAction = @@ -246,6 +248,7 @@ export const useModEventList = ( addedTags, removedTags, reportTypes, + policies, limit, } = listState const queryParams: ToolsOzoneModerationQueryEvents.QueryParams = { @@ -335,6 +338,10 @@ export const useModEventList = ( }) } + if (filterTypes.includes(MOD_EVENTS.TAKEDOWN) && policies) { + queryParams.policies = policies + } + const { data } = await labelerAgent.tools.ozone.moderation.queryEvents({ ...queryParams, }) @@ -372,6 +379,7 @@ export const useModEventList = ( listState.createdBy || listState.subject || listState.oldestFirst || + listState.policies.length > 0 || listState.reportTypes.length > 0 || listState.addedLabels.length > 0 || listState.removedLabels.length > 0 || diff --git a/components/reports/ModerationForm/ActionError.tsx b/components/reports/ModerationForm/ActionError.tsx index 494e4d3b..65cc73a1 100644 --- a/components/reports/ModerationForm/ActionError.tsx +++ b/components/reports/ModerationForm/ActionError.tsx @@ -5,6 +5,10 @@ const ActionErrors = { title: 'No blobs selected', description: 'You must select at least one blob to be diverted', }, + 'policy-selection-required': { + title: 'No takedown policy selected', + description: 'You must select the policy used for the takedown', + }, } export type ActionErrorKey = keyof typeof ActionErrors | string @@ -13,7 +17,7 @@ export const ActionError = ({ error }: { error: ActionErrorKey }) => { if (ActionErrors[error]) { return ( -

{ActionErrors[error].title}

+

{ActionErrors[error].title}

{ActionErrors[error].description}

) diff --git a/components/reports/ModerationForm/ActionPolicySelector.tsx b/components/reports/ModerationForm/ActionPolicySelector.tsx new file mode 100644 index 00000000..bc5cf56d --- /dev/null +++ b/components/reports/ModerationForm/ActionPolicySelector.tsx @@ -0,0 +1,190 @@ +import { usePolicyListSetting } from '@/setting/policy/usePolicyList' +import { useServerConfig } from '@/shell/ConfigurationContext' +import { ToolsOzoneTeamDefs } from '@atproto/api' +import { Combobox, Transition } from '@headlessui/react' +import { + ArrowTopRightOnSquareIcon, + CheckIcon, + ChevronUpDownIcon, +} from '@heroicons/react/24/solid' +import { Fragment, useRef, useState } from 'react' + +export const ActionPolicySelector = ({ + defaultPolicy, + onSelect, + name = 'policies', +}: { + name?: string + defaultPolicy?: string + onSelect?: (name: string) => void +}) => { + const { data, isLoading } = usePolicyListSetting() + const [selected, setSelected] = useState(defaultPolicy) + const policyList = Object.values(data?.value || {}) + + return ( + <> + { + setSelected(selectedPolicy) + onSelect?.(selectedPolicy) + }} + name={name} + > + + + + ) +} + +export const ActionPoliciesSelector = ({ + defaultPolicies, + onSelect, + name = 'policies', +}: { + name?: string + defaultPolicies?: string[] + onSelect?: (names: string[]) => void +}) => { + const { data, isLoading } = usePolicyListSetting() + const [selected, setSelected] = useState(defaultPolicies) + const policyList = Object.values(data?.value || {}) + + return ( + <> + { + setSelected(selectedPolicies) + onSelect?.(selectedPolicies) + }} + name={name} + > + + + + ) +} + +const ActionPolicyList = ({ policyList }: { policyList: any[] }) => { + const [query, setQuery] = useState('') + const [isFocused, setIsFocused] = useState(false) + const matchingPolicies = policyList + ?.filter((tpl) => { + if (query.length) { + return tpl.name.toLowerCase().includes(query.toLowerCase()) + } + + return true + }) + .sort((prev, next) => prev.name.localeCompare(next.name)) + + return ( +
+
+ setQuery(event.target.value)} + onFocus={() => setIsFocused(true)} + onBlur={() => setIsFocused(false)} + displayValue={(values) => { + // When focused from blur, display empty value allowing user to input their search query + return isFocused + ? '' + : // when blurred, selected values may be an array of strings or just a string + // when array, we apply the OR condition between multiple policies so show that + Array.isArray(values) + ? values.join(' OR ') + : values || '' + }} + placeholder="Select policy. Type or click arrows to see all policies" + /> + + +
+ setQuery('')} + > + + {!matchingPolicies?.length ? ( + + ) : ( + matchingPolicies?.map((tpl) => ( + + `relative cursor-default select-none py-2 pl-10 pr-4 ${ + active + ? 'bg-gray-100 dark:bg-slate-600 text-gray-900 dark:text-gray-200' + : 'text-gray-900 dark:text-gray-200' + }` + } + value={tpl.name} + > + {({ selected, active }) => ( + <> + {selected ? ( + + + ) : null} +
+
+

+ {tpl.name} +

+

{tpl.description}

+
+
+ + )} +
+ )) + )} +
+
+
+ ) +} + +const NoPolicyOption = ({ query = '' }: { query?: string }) => { + const isAdmin = useServerConfig().role === ToolsOzoneTeamDefs.ROLEADMIN + return ( +
+ No policy found {query.length ? `matching "${query}"` : ''}. + {isAdmin && ( + + Add policy + + )} + +
+ ) +} diff --git a/components/setting/policy/Editor.tsx b/components/setting/policy/Editor.tsx new file mode 100644 index 00000000..88fbdf8d --- /dev/null +++ b/components/setting/policy/Editor.tsx @@ -0,0 +1,69 @@ +import React from 'react' +import { usePolicyListEditor } from './usePolicyList' +import { FormLabel, Input, Textarea } from '@/common/forms' +import { ActionButton } from '@/common/buttons' +import { DocumentCheckIcon } from '@heroicons/react/24/solid' + +export const PolicyEditor = ({ + onCancel, + onSuccess, +}: { + onSuccess: () => void + onCancel: () => void +}) => { + const { onSubmit, mutation } = usePolicyListEditor() + return ( +
{ + onSubmit(e).then(onSuccess).catch(onCancel) + }} + > + + + + +