diff --git a/.eslintrc.json b/.eslintrc.json index f67ee7b41..59707e141 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -46,6 +46,9 @@ "typescript": true, "node": true }, + "import/no-extraneous-dependencies": { + "devDependencies": "true" + }, "polyfills": [ "es:all", "fetch", @@ -338,4 +341,4 @@ } } ] -} \ No newline at end of file +} diff --git a/package.json b/package.json index 99d9e7ce9..125ad95e6 100644 --- a/package.json +++ b/package.json @@ -104,6 +104,7 @@ "es-toolkit": "^1.27.0", "eslint-plugin-formatjs": "^5.2.2", "exifr": "^7.1.3", + "fuzzy-search": "^3.2.1", "graphemesplit": "^2.4.4", "html-react-parser": "^5.0.0", "http-link-header": "^1.0.2", @@ -163,6 +164,7 @@ "@testing-library/react": "^14.0.0", "@testing-library/react-hooks": "^8.0.1", "@testing-library/user-event": "^14.5.1", + "@types/fuzzy-search": "^2.1.5", "@typescript-eslint/eslint-plugin": "^7.0.0", "@typescript-eslint/parser": "^7.0.0", "@vitejs/plugin-react-swc": "^3.7.2", diff --git a/src/api/hooks/admin/index.ts b/src/api/hooks/admin/index.ts index 472cdc86e..eec0e5bd7 100644 --- a/src/api/hooks/admin/index.ts +++ b/src/api/hooks/admin/index.ts @@ -3,4 +3,5 @@ export { useModerationLog } from './useModerationLog.ts'; export { useRelays } from './useRelays.ts'; export { useRules } from './useRules.ts'; export { useSuggest } from './useSuggest.ts'; -export { useVerify } from './useVerify.ts'; \ No newline at end of file +export { useVerify } from './useVerify.ts'; +export { useModerationPolicies } from './useModerationPolicies.ts'; diff --git a/src/api/hooks/admin/useModerationPolicies.ts b/src/api/hooks/admin/useModerationPolicies.ts new file mode 100644 index 000000000..608cd42f0 --- /dev/null +++ b/src/api/hooks/admin/useModerationPolicies.ts @@ -0,0 +1,69 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; + +import { MastodonResponse } from 'soapbox/api/MastodonResponse.ts'; +import { useApi } from 'soapbox/hooks/useApi.ts'; +import { PolicyItem, PolicyResponse, PolicySpec } from 'soapbox/utils/policies.ts'; + +const useModerationPolicies = () => { + const api = useApi(); + const queryClient = useQueryClient(); + + const handleResponse = async (response: MastodonResponse, message: string) => { + const details = await response.error() + .then(v => v?.error || 'Unknown error'); + if (!response.ok) throw new Error(`${message}: ${details}`); + const data = await response.json(); + // Check if the response contains an error + if (data && 'error' in data) throw new Error(data.error); + return data; + }; + + const allPoliciesQuery = useQuery({ + queryKey: ['admin', 'moderation_policies'], + queryFn: async () => { + return await handleResponse( + await api.get('/api/v1/admin/ditto/policies'), + 'Error fetching policy list', + ) as Promise; + }, + }); + + // Fetch current policy + const currentPolicyQuery = useQuery({ + queryKey: ['admin', 'current_moderation_policy'], + queryFn: async () => { + return await handleResponse( + await api.get('/api/v1/admin/ditto/policies/current'), + 'Error fetching current policy', + ) as Promise; + }, + }); + + // Update current policy + const updatePolicyMutation = useMutation({ + mutationFn: async (spec: PolicySpec) => { + return await handleResponse( + await api.put('/api/v1/admin/ditto/policies/current', spec), + 'Error updating policy', + ) as Promise; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin', 'current_moderation_policy'] }); // Refetch after update + }, + }); + + return { + allPolicies: allPoliciesQuery.data, + storedPolicies: currentPolicyQuery.data, + isLoading: allPoliciesQuery.isLoading || currentPolicyQuery.isLoading, + isFetched: currentPolicyQuery.isFetched, + updatePolicy: updatePolicyMutation.mutate, + isUpdating: updatePolicyMutation.isPending, + allPoliciesError: allPoliciesQuery.error, + storedPoliciesError: currentPolicyQuery.error, + allPoliciesIsError: allPoliciesQuery.isError, + storedPoliciesIsError: currentPolicyQuery.isError, + }; +}; + +export { useModerationPolicies }; diff --git a/src/components/fuzzy-search-input.tsx b/src/components/fuzzy-search-input.tsx new file mode 100644 index 000000000..7ad9b797a --- /dev/null +++ b/src/components/fuzzy-search-input.tsx @@ -0,0 +1,292 @@ +import FuzzySearch from 'fuzzy-search'; +import React, { useState, useRef, useCallback, useEffect, useId } from 'react'; +import { useIntl, MessageDescriptor, defineMessages } from 'react-intl'; + +import Input from 'soapbox/components/ui/input.tsx'; + +const messages = defineMessages({ + defaultAriaLabel: { id: 'soapbox.fuzzy_search_input.default_aria_label', defaultMessage: 'Fuzzy search input field.' }, +}); + +type PlaceholderText = string | MessageDescriptor; + +interface FuzzySearchInputProps { + /** The array of objects or strings to search through. */ + data: T[]; + /** An array of keys within the objects in `data` to search against. Ignored if `data` is string[]. */ + keys: (keyof T)[]; + /** Callback function invoked when an item is selected from the suggestions. */ + onSelection: (selection: T | null, clearField: () => void) => void; + /** Optional: The key to display in the suggestion list. Defaults to the first key in the `keys` prop or the item itself if data is string[]. */ + displayKey?: keyof T; + /** Optional: Placeholder text for the input field. If a string is provided, it will be used as a static placeholder. */ + placeholder?: PlaceholderText; + /** Optional: Array of placeholders to rotate through. Takes precedence over placeholder if both are provided. */ + placeholders?: PlaceholderText[]; + /** Optional, but strongly recommended: aria-label text for the element's placeholder. Use if no label for the field is given. */ + ariaLabel?: PlaceholderText; + /** Optional: Interval in milliseconds to change placeholders. Defaults to 5000ms (5 seconds). */ + placeholderChangeInterval?: number; + /** + * Optional: Custom search function to override the default fuzzy search. */ + searchFn?: SearchImpl; + /** Optional: Additional classes for the main container div */ + className?: string; + /** Optional: Additional classes for the input field */ + inputClassName?: string; + /** Base ID to use for input components. Will be generated if not specified */ + baseId?: string; + /** Component to use to optionally override suggestion rendering. */ + renderSuggestion?: React.ComponentType<{ item: T }>; +} + +interface SearchImpl { + /** + * @param data The data to search through. Should be an array of Records + * @param query The query to search for. + * @param keys The keys in the record to search through + * @returns A list of search results. + */ + | string>(data: T[], query: string, keys: (keyof T)[]): T[]; +} + +const defaultSearch: SearchImpl = (data, query, keys) => { + const searcher = new FuzzySearch(data as any[], keys as string[], { + caseSensitive: false, + sort: true, + }); + return query ? searcher.search(query) : data; +}; + +function FuzzySearchInput | string>({ + data, + keys, + onSelection, + displayKey, + placeholder = 'Search...', + placeholders, + placeholderChangeInterval = 5000, + searchFn = defaultSearch, + className = '', + baseId, + inputClassName = '', + ariaLabel = messages.defaultAriaLabel, + renderSuggestion: FuzzySearchSuggestion, +}: FuzzySearchInputProps) { + const intl = useIntl(); + const [inputValue, setInputValue] = useState(''); + const [suggestions, setSuggestions] = useState([]); + const [showSuggestions, setShowSuggestions] = useState(false); + const [activeIndex, setActiveIndex] = useState(-1); + + // dynamic placeholder state + const [currentPlaceholder, setCurrentPlaceholder] = useState( + typeof placeholder === 'string' ? placeholder : intl.formatMessage(placeholder), + ); + const placeholderIntervalRef = useRef | null>(null); + + const containerRef = useRef(null); + // Generate unique IDs for ARIA attributes if no baseId is provided + const generatedId = useId(); + const componentBaseId = baseId ?? generatedId; + const inputId = `${componentBaseId}-input`; + const listboxId = `${componentBaseId}-listbox`; + const getOptionId = (index: number) => `${componentBaseId}-option-${index}`; + + // Helper function to format a placeholder (either string or MessageDescriptor) + const formatPlaceholder = useCallback((text: PlaceholderText): string => { + if (typeof text === 'string') { + return text; + } + return intl.formatMessage(text); + }, [intl]); + + // Handle placeholder rotation if placeholders array is provided + useEffect(() => { + // Clear any existing interval + if (placeholderIntervalRef.current) { + clearInterval(placeholderIntervalRef.current); + placeholderIntervalRef.current = null; + } + + // If we have multiple placeholders, set up rotation + if (placeholders && placeholders.length > 1) { + // Set initial placeholder + const randomIndex = Math.floor(Math.random() * placeholders.length); + setCurrentPlaceholder(formatPlaceholder(placeholders[randomIndex])); + + // Set up interval to change placeholder + placeholderIntervalRef.current = setInterval(() => { + const randomIndex = Math.floor(Math.random() * placeholders.length); + setCurrentPlaceholder(formatPlaceholder(placeholders[randomIndex])); + }, placeholderChangeInterval); + } else if (placeholders && placeholders.length === 1) { + // If just one placeholder in the array, use it statically + setCurrentPlaceholder(formatPlaceholder(placeholders[0])); + } else if (placeholder) { + // Fall back to the single placeholder prop + setCurrentPlaceholder(formatPlaceholder(placeholder)); + } + + // Clean up interval on unmount + return () => { + if (placeholderIntervalRef.current) { + clearInterval(placeholderIntervalRef.current); + } + }; + }, [placeholder, placeholders, placeholderChangeInterval, formatPlaceholder]); + + const getDisplayText = useCallback((item: T): string => { + if (typeof item === 'string') { + return item; + } + const key = displayKey ?? keys[0]; + return key ? String(item[key]) : 'Invalid displayKey'; + }, [displayKey, keys]); + + const handleInputChange = useCallback((event: React.ChangeEvent) => { + const query = event.target.value; + setInputValue(query); + setActiveIndex(-1); + + const results = searchFn(data, query, keys); + setSuggestions(results); + setShowSuggestions(results.length > 0 || query.trim() === ''); + }, [searchFn, data, keys]); + + const handleSelectSuggestion = useCallback((suggestion: T) => { + setInputValue(getDisplayText(suggestion)); + setSuggestions([]); + setShowSuggestions(false); + setActiveIndex(-1); + onSelection(suggestion, () => setInputValue('')); + // Optionally focus the input again if needed, though blur might occur naturally + // containerRef.current?.querySelector('input')?.focus(); + }, [getDisplayText, onSelection]); + + const handleKeyDown = useCallback((event: React.KeyboardEvent) => { + const listIsVisible = showSuggestions && suggestions.length > 0; + + if (event.key === 'ArrowDown') { + event.preventDefault(); + if (!listIsVisible && inputValue.trim()) { + // If list isn't visible but there's input, try searching/showing + const results = searchFn(data, inputValue.trim(), keys); + if (results.length > 0) { + setSuggestions(results); + setShowSuggestions(true); + setActiveIndex(0); // Start at the first item + } + } else { + setActiveIndex((prevIndex) => + prevIndex >= suggestions.length - 1 ? 0 : prevIndex + 1, + ); + } + } else if (event.key === 'ArrowUp') { + event.preventDefault(); + if (!listIsVisible) return; + setActiveIndex((prevIndex) => + prevIndex <= 0 ? suggestions.length - 1 : prevIndex - 1, + ); + } else if (event.key === 'Enter') { + if (!listIsVisible || activeIndex < 0) return; + event.preventDefault(); + handleSelectSuggestion(suggestions[activeIndex]); + } else if (event.key === 'Escape') { + if (!listIsVisible) return; + event.preventDefault(); + setShowSuggestions(false); + setActiveIndex(-1); + setSuggestions([]); // Clear suggestions on escape + } + }, [showSuggestions, suggestions, activeIndex, handleSelectSuggestion, inputValue, searchFn, data, keys]); + + const handleFocus = useCallback(() => { + if (inputValue.trim() === '') { + const results = searchFn(data, '', keys); + setSuggestions(results); + setShowSuggestions(results.length > 0); + } + }, [inputValue, searchFn, data, keys]); + + const handleBlur = useCallback(() => { + + // Delay hiding to allow clicking on options + setTimeout(() => { + // Check if focus is still somehow within the container (e.g., clicked an option) + // If focus moved outside, hide the list. + if (containerRef.current && !containerRef.current.contains(document.activeElement)) { + setShowSuggestions(false); + // Don't reset activeIndex here, selection might have happened + } + }, 150); + }, []); + + // Check if user clicked outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(event.target as Node)) { + setShowSuggestions(false); + // Don't reset activeIndex here, allows inspection if needed + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, []); + + const label = typeof ariaLabel === 'string' ? ariaLabel : intl.formatMessage(ariaLabel); + + return ( +
+ 0 ? listboxId : undefined} + aria-expanded={showSuggestions && suggestions.length > 0} + aria-haspopup='listbox' + aria-activedescendant={activeIndex > -1 ? getOptionId(activeIndex) : undefined} + className={`${inputClassName}`} + /> + {showSuggestions && suggestions.length > 0 && ( +
    + {suggestions.map((item, index) => { + const isActive = index === activeIndex; + const optionId = getOptionId(index); + + return ( +
  • handleSelectSuggestion(item)} + // onMouseEnter helps sync visual hover with keyboard activeIndex for usability + onMouseEnter={() => setActiveIndex(index)} + className={`cursor-pointer p-2 ${isActive ? 'bg-gray-100 dark:bg-gray-700' : 'hover:bg-gray-100 dark:hover:bg-gray-700'}`} + > + {FuzzySearchSuggestion ? : getDisplayText(item)} +
  • + ); + })} +
+ )} +
+ ); +} + +export default FuzzySearchInput; diff --git a/src/features/admin/components/policies/Policy.tsx b/src/features/admin/components/policies/Policy.tsx new file mode 100644 index 000000000..f23b2d2e7 --- /dev/null +++ b/src/features/admin/components/policies/Policy.tsx @@ -0,0 +1,58 @@ +import CloseIcon from '@tabler/icons/outline/x.svg'; +import { FC } from 'react'; +import { defineMessages, useIntl } from 'react-intl'; + +import { Card, CardHeader, CardBody } from 'soapbox/components/ui/card.tsx'; +import Icon from 'soapbox/components/ui/icon.tsx'; +import { PolicySpecItem, PolicyItem, PolicyState, PolicyAction } from 'soapbox/utils/policies.ts'; + +import { PolicyFields } from './PolicyFields.tsx'; + +const messages = defineMessages({ + removePolicy: { id: 'admin.policies.remove_policy', defaultMessage: 'Remove policy' }, +}); + +export const Policy: FC<{ + policy: PolicySpecItem; + registry: PolicyItem[]; + state: PolicyState; + dispatch: React.Dispatch; + intl: ReturnType; +}> = ({ policy, registry, state, dispatch, intl }) => { + const def = registry.find(item => item.internalName === policy.name); + if (!def) return null; + + const handleRemovePolicy = () => { + dispatch({ type: 'REMOVE_POLICY', name: policy.name }); + }; + + return ( + + +
+ {def.name} + +
+
+ + {Object.entries(def.parameters).map(([fieldName, schema]) => ( + + ))} + +
+ ); +}; diff --git a/src/features/admin/components/policies/PolicyFields.tsx b/src/features/admin/components/policies/PolicyFields.tsx new file mode 100644 index 000000000..6c648a762 --- /dev/null +++ b/src/features/admin/components/policies/PolicyFields.tsx @@ -0,0 +1,150 @@ +import CloseIcon from '@tabler/icons/outline/x.svg'; +import { FC, useRef } from 'react'; +import { defineMessages, useIntl } from 'react-intl'; + +import Button from 'soapbox/components/ui/button.tsx'; +import Icon from 'soapbox/components/ui/icon.tsx'; +import Input from 'soapbox/components/ui/input.tsx'; +import Stack from 'soapbox/components/ui/stack.tsx'; +import { FieldItem, PolicyAction, PolicyState, stringifyDefault } from 'soapbox/utils/policies.ts'; + +const messages = defineMessages({ + removeValue: { id: 'admin.policies.remove_value', defaultMessage: 'Remove value' }, + addValue: { id: 'admin.policies.add_value', defaultMessage: 'Add' }, +}); + +const MultiValueBadge: FC<{ + value: string | number; + onRemove: () => void; + intl: ReturnType; +}> = ({ value, onRemove, intl }) => { + return ( +
+ {value} + +
+ ); +}; + + +const getInputPlaceholder = (schema: FieldItem) => { + if (schema.default) return `Default: ${stringifyDefault(schema.default)}`; + if (schema.optional) return '(Optional)'; +}; + +const getInputType = (type: FieldItem['type']) => { + switch (type) { + case 'multi_number': + case 'number': + return 'number'; + case 'boolean': + return 'checkbox'; + } + return 'text'; +}; + +export const PolicyFields: FC<{ + schema: FieldItem; + name: string; + policyName: string; + state: PolicyState; + dispatch: React.Dispatch; + intl: ReturnType; +}> = ({ schema, name, policyName, state, dispatch, intl }) => { + const value = state.fields[`${policyName}.${name}`]; + const inputRef = useRef(null); + + const handleChange = (e: React.ChangeEvent) => { + const newValue = schema.type === 'number' ? Number(e.target.value) : e.target.value; + dispatch({ type: 'UPDATE_FIELD', policyName, fieldName: name, value: newValue }); + }; + + const handleAddMultiValue = () => { + const inputValue = inputRef.current?.value; + if (!inputValue?.trim()) return; + + const currentValue = Array.isArray(value) ? value : []; + + // Convert to number for multi_number fields + const processedValue = schema.type === 'multi_number' + ? Number(inputValue) + : inputValue; + + // Check for NaN when converting to number + if (schema.type === 'multi_number' && isNaN(processedValue as number)) { + // Show error or return + return; + } + + if (!currentValue.includes(processedValue)) { + dispatch({ type: 'ADD_MULTI_VALUE', policyName, fieldName: name, value: inputValue }); + } + + if (inputRef.current) { + inputRef.current.value = ''; + } + }; + const handleRemoveMultiValue = (valueToRemove: string | number) => { + dispatch({ type: 'REMOVE_MULTI_VALUE', policyName, fieldName: name, value: valueToRemove }); + }; + + if (!schema.type.startsWith('multi_')) { + return ( + +
{schema.description}
+ { + schema.type === 'boolean' ? + { + dispatch({ + type: 'UPDATE_FIELD', + policyName, + fieldName: name, + value: e.target.checked, + }); + }} + /> : + + } +
+ ); + } + + return ( + +
{schema.description}
+ +
+ + +
+
+ {((value || []) as (string | number)[]).map((v) => ( + handleRemoveMultiValue(v)} + /> + ))} +
+
+
+ ); +}; diff --git a/src/features/admin/components/policies/PolicyHelpModal.tsx b/src/features/admin/components/policies/PolicyHelpModal.tsx new file mode 100644 index 000000000..4bb43305e --- /dev/null +++ b/src/features/admin/components/policies/PolicyHelpModal.tsx @@ -0,0 +1,59 @@ +import { FC } from 'react'; +import { defineMessages, useIntl } from 'react-intl'; + +import Modal from 'soapbox/components/ui/modal.tsx'; + +const messages = defineMessages({ + welcomeDescription: { id: 'admin.policies.help.description', defaultMessage: 'Policy Manager allows you to configure moderation and content policies for your instance.' }, + welcomeStep1: { id: 'admin.policies.help.step1', defaultMessage: '1. Use the search bar to find policies you want to add' }, + welcomeStep2: { id: 'admin.policies.help.step2', defaultMessage: '2. Configure each policy with your desired settings' }, + welcomeStep3: { id: 'admin.policies.help.step3', defaultMessage: '3. Click Save to apply the changes' }, + welcomeTip: { id: 'admin.policies.help.tip', defaultMessage: 'Tip: You can add multiple policies to create a comprehensive moderation strategy' }, +}); + +interface PolicyHelpModalProps { + title: string; + onClose: () => void; + confirmText: string; +} + +const PolicyHelpModal: FC = ({ title, onClose, confirmText }) => { + const intl = useIntl(); + + return ( +
+
+ +
+

+ {intl.formatMessage(messages.welcomeDescription)} +

+ +
+

+ {intl.formatMessage(messages.welcomeStep1)} +

+

+ {intl.formatMessage(messages.welcomeStep2)} +

+

+ {intl.formatMessage(messages.welcomeStep3)} +

+
+ +
+ {intl.formatMessage(messages.welcomeTip)} +
+
+
+
+
+ ); +}; + +export default PolicyHelpModal; \ No newline at end of file diff --git a/src/features/admin/hooks/usePolicyReducer.ts b/src/features/admin/hooks/usePolicyReducer.ts new file mode 100644 index 000000000..6968fe8a7 --- /dev/null +++ b/src/features/admin/hooks/usePolicyReducer.ts @@ -0,0 +1,103 @@ +import { isEqual } from 'es-toolkit'; +import { useReducer, useRef, useEffect, useMemo } from 'react'; + +import { PolicyState, PolicySpecItem, PolicyParam, PolicyItem, PolicyAction } from 'soapbox/utils/policies.ts'; + +// Reducer function +export const createPolicyReducer = (allPolicies: PolicyItem[]) => (state: PolicyState, action: PolicyAction): PolicyState => { + switch (action.type) { + case 'ADD_POLICY': { + if (state.policies.some(p => p.name === action.policy.name)) { + return state; // Don't add duplicate + } + + // Initialize fields for the new policy + const newFields = { ...state.fields }; + const policyDef = allPolicies.find(p => p.internalName === action.policy.name); + + if (policyDef) { + Object.entries(policyDef.parameters).forEach(([fieldName, schema]) => { + const fieldKey = `${action.policy.name}.${fieldName}`; + if (!newFields[fieldKey]) { + newFields[fieldKey] = schema.type.startsWith('multi_') ? [] : (schema.default ?? ''); + } + }); + } + + return { + ...state, + policies: [action.policy, ...state.policies], + fields: newFields, + }; + } + case 'REMOVE_POLICY': { + const fieldsToKeep = Object.entries({ ...state.fields }) + .filter(([key]) => !key.startsWith(`${action.name}.`)); + + return { + ...state, + policies: state.policies.filter(policy => policy.name !== action.name), + fields: Object.fromEntries(fieldsToKeep), + }; + } + case 'UPDATE_FIELD': + return { + ...state, + fields: { + ...state.fields, + [`${action.policyName}.${action.fieldName}`]: action.value, + }, + }; + case 'ADD_MULTI_VALUE': { + const fieldKey = `${action.policyName}.${action.fieldName}`; + const current = (state.fields[fieldKey] as (string | number)[]) || []; + const policyDef = allPolicies.find(p => p.internalName === action.policyName); + const paramSchema = policyDef?.parameters[action.fieldName]; + const value = paramSchema?.type === 'multi_number' && typeof action.value === 'string' + ? Number(action.value) + : action.value; + + return { + ...state, + fields: { + ...state.fields, + [fieldKey]: [...current, value], + }, + }; + } + case 'REMOVE_MULTI_VALUE': { + const fieldKey = `${action.policyName}.${action.fieldName}`; + const current = (state.fields[fieldKey] as (string | number)[]) || []; + return { + ...state, + fields: { + ...state.fields, + [fieldKey]: current.filter(v => v !== action.value), + }, + }; + } + case 'INITIALIZE_FIELDS': + return { + ...state, + fields: action.fields, + }; + default: + return state; + } +}; + +export const usePolicyReducer = (allPolicies: PolicyItem[], initialPolicies: PolicySpecItem[], initialFields: Record) => { + const policyReducer = useMemo(() => createPolicyReducer(allPolicies), [allPolicies]); + const [state, dispatch] = useReducer(policyReducer, { policies: initialPolicies, fields: initialFields }); + + const prevInitialFields = useRef>(); + + useEffect(() => { + if (initialFields && !isEqual(prevInitialFields.current, initialFields)) { + dispatch({ type: 'INITIALIZE_FIELDS', fields: initialFields }); + prevInitialFields.current = initialFields; + } + }, [initialFields]); + + return [state, dispatch] as const; +}; diff --git a/src/features/admin/policy-manager.tsx b/src/features/admin/policy-manager.tsx new file mode 100644 index 000000000..57fb85fb8 --- /dev/null +++ b/src/features/admin/policy-manager.tsx @@ -0,0 +1,312 @@ +import { isEqual } from 'es-toolkit'; +import { FC, useEffect, useMemo, useRef, useState } from 'react'; +import { defineMessages, useIntl } from 'react-intl'; + +import { useModerationPolicies } from 'soapbox/api/hooks/admin/index.ts'; +import FuzzySearchInput from 'soapbox/components/fuzzy-search-input.tsx'; +import { Button } from 'soapbox/components/ui/button.tsx'; +import { Card } from 'soapbox/components/ui/card.tsx'; +import { Column } from 'soapbox/components/ui/column.tsx'; +import Spinner from 'soapbox/components/ui/spinner.tsx'; +import Stack from 'soapbox/components/ui/stack.tsx'; +import { Policy } from 'soapbox/features/admin/components/policies/Policy.tsx'; +import PolicyHelpModal from 'soapbox/features/admin/components/policies/PolicyHelpModal.tsx'; +import { usePolicyReducer } from 'soapbox/features/admin/hooks/usePolicyReducer.ts'; +import toast from 'soapbox/toast.tsx'; +import { PolicyItem, PolicyParam, PolicyParams, PolicySpec, PolicySpecItem } from 'soapbox/utils/policies.ts'; + +const messages = defineMessages({ + heading: { id: 'admin.policies.heading', defaultMessage: 'Manage Policies' }, + searchPlaceholder: { id: 'admin.policies.search_placeholder', defaultMessage: 'What do you want to do?' }, + searchHeading: { id: 'admin.policies.search_heading', defaultMessage: 'I want to...' }, + noPolicyConfigured: { id: 'admin.policies.no_policies_configured', defaultMessage: 'No policies configured! Use the search bar above to get started.' }, + removeValue: { id: 'admin.policies.remove_value', defaultMessage: 'Remove value' }, + welcomeTitle: { id: 'admin.policies.welcome.title', defaultMessage: 'Welcome to Policy Manager' }, + welcomeGetStarted: { id: 'admin.policies.welcome.get_started', defaultMessage: 'Get Started' }, + helpTitle: { id: 'admin.policies.help.title', defaultMessage: 'Help' }, + helpButton: { id: 'admin.policies.help.button', defaultMessage: 'Help' }, + okay: { id: 'admin.policies.help.okay', defaultMessage: 'Okay' }, + policyExists: { id: 'admin.policies.policy_exists', defaultMessage: 'Policy already exists!' }, + searchFieldLabel: { id: 'admin.policies.search_label', defaultMessage: 'Policy search field' }, + policyUpdateError: { id: 'admin.policies.update_error', defaultMessage: 'Error updating policies: {error}' }, + policySaveSuccess: { id: 'admin.policies.update_success', defaultMessage: 'Policies saved successfully.' }, +}); + +const PolicySuggestion: FC<{ item: PolicyItem }> = ({ item }) => { + return ( + +
{item.name}
+
{item.description}
+
+ ); +}; + +const PolicyManager: FC = () => { + const intl = useIntl(); + const [showHelpModal, setShowHelpModal] = useState(false); + const [isWelcomeDialog, setIsWelcomeDialog] = useState(false); + const { + allPolicies = [], + isLoading, + isFetched, + storedPolicies, + updatePolicy, + isUpdating, + allPoliciesError, + storedPoliciesError, + allPoliciesIsError, + storedPoliciesIsError, + } = useModerationPolicies(); + // get the current set of policies out of the API response + const initialPolicies = storedPolicies?.spec?.policies ?? []; + + // Generate dynamic placeholders from policy names + const dynamicPlaceholders = useMemo(() => { + if (allPolicies.length === 0) { + return [messages.searchPlaceholder]; + } + + return [ + ...allPolicies.map(policy => policy.name.toLowerCase()), + ]; + }, [allPolicies]); + + // initialFields is used to set up the reducer. stores the initial value of + // all the fields from the current policy and falls back to the default if + // the value isn't present. + const initialFields = useMemo(() => { + const policyMap = allPolicies.reduce((acc, policy) => { + acc[policy.internalName] = policy; + return acc; + }, {} as Record); + + const fields: Record = {}; + + for (const policy of initialPolicies) { + const item = policyMap[policy.name]; + if (!item) continue; + + for (const [key, val] of Object.entries(item.parameters)) { + const fieldKey = `${policy.name}.${key}`; + if (policy.params?.[key] !== undefined) { + fields[fieldKey] = policy.params[key]; + } else if (val.type.startsWith('multi_')) { + fields[fieldKey] = []; + } else { + fields[fieldKey] = val.default ?? ''; + } + } + } + + return fields; + }, [allPolicies, initialPolicies]); // Changed from storedPolicies to initialPolicies + + const [state, dispatch] = usePolicyReducer(allPolicies, initialPolicies, initialFields); + + // Update policies when data is loaded + useEffect(() => { + if (isFetched && storedPolicies && initialPolicies.length > 0 && state.policies.length === 0) { + // If policies are loaded from the server but not in our state, update the state + initialPolicies.forEach(policy => { + dispatch({ type: 'ADD_POLICY', policy }); + }); + } + }, [isFetched, storedPolicies]); + + // Check if this is the first time loading using localStorage + useEffect(() => { + if (isFetched && !isLoading) { + try { + // Check if the user has seen the welcome dialog before + const hasSeenWelcome = localStorage.getItem('soapbox:policies:welcome_shown'); + + if (!hasSeenWelcome) { + setShowHelpModal(true); + setIsWelcomeDialog(true); + } + } catch (error) { + // localStorage is unavailable, default to showing welcome + setShowHelpModal(true); + setIsWelcomeDialog(true); + } + } + }, [isFetched, isLoading]); + + // Function to handle help dialog close + const handleHelpClose = () => { + setShowHelpModal(false); + + // If this was the welcome dialog, store that the user has seen it + if (isWelcomeDialog) { + setIsWelcomeDialog(false); + try { + localStorage.setItem('soapbox:policies:welcome_shown', 'true'); + } catch (error) { + // Ignore localStorage errors + console.warn('Could not store welcome dialog state in localStorage', error); + } + } + }; + + // Function to show help dialog + const showHelp = () => { + setIsWelcomeDialog(false); // Not the welcome dialog + setShowHelpModal(true); + }; + + // Initialize fields when storedPolicies loads + const prevInitialFields = useRef>(); + + useEffect(() => { + if (initialFields && !isEqual(prevInitialFields.current, initialFields)) { + dispatch({ type: 'INITIALIZE_FIELDS', fields: initialFields }); + prevInitialFields.current = initialFields; + } + }, [initialFields]); + + const handleSelection = (policy: PolicyItem | null, clear: () => void) => { + if (policy) { + const alreadyExists = state.policies.some(p => p.name === policy.internalName); + + if (!alreadyExists) { + const newPolicy: PolicySpecItem = { name: policy.internalName, params: {} }; + dispatch({ type: 'ADD_POLICY', policy: newPolicy }); + } else { + toast.error(intl.formatMessage(messages.policyExists)); + } + } + clear(); // clear the text field + }; + + if (isLoading) return ( + + + + ); + + const renderPolicies = () => { + if (state.policies.length === 0) { + // Only show "no policies" message when we're certain data has loaded + if (!isLoading && isFetched) { + return ( + + {intl.formatMessage(messages.noPolicyConfigured)} + + ); + } else if (allPoliciesIsError) { + return ( + + {allPoliciesError?.message} + + ); + } else if (storedPoliciesIsError) { + return ( + + {storedPoliciesError?.message} + + ); + } else { + // If we're not loading but data isn't fetched yet, this prevents flashing "no policies" + return ( + + + + ); + } + } + + return ( + + {state.policies.map(policy => ( + + ))} + + ); + }; + + const handleSave = async () => { + const policyParams = Object.entries(state.fields) + .map(([key, value]) => { + const [policy, paramName] = key.split('.'); + return { policy, paramName, value }; + }); + + const policies: Record = {}; + + for (const param of policyParams) { + policies[param.policy] ??= {}; + policies[param.policy][param.paramName] = param.value; + } + + const policySpec: PolicySpec = { + policies: Object.entries(policies).map(([policyName, params]) => ({ params, name: policyName })), + }; + + updatePolicy(policySpec, { + onError(error) { + toast.error(intl.formatMessage(messages.policyUpdateError, { error: error.message }), { + duration: Infinity, + }); + }, + onSuccess() { + toast.success(intl.formatMessage(messages.policySaveSuccess)); + }, + }); + }; + + return ( + + {showHelpModal && ( + + )} +
+
+
+ {intl.formatMessage(messages.searchHeading)} +
+ +
+ + data={allPolicies} + keys={['name', 'description']} + onSelection={handleSelection} + displayKey='name' + placeholders={dynamicPlaceholders} + ariaLabel={messages.searchFieldLabel} + className='w-full' + renderSuggestion={PolicySuggestion} + /> +
+ {renderPolicies()} +
+
+
+ ); +}; + +export default PolicyManager; diff --git a/src/features/admin/tabs/dashboard.tsx b/src/features/admin/tabs/dashboard.tsx index a3d3e4378..d53803f6d 100644 --- a/src/features/admin/tabs/dashboard.tsx +++ b/src/features/admin/tabs/dashboard.tsx @@ -108,6 +108,11 @@ const Dashboard: React.FC = () => { label={} /> + } + /> + {features.nostr && ( = ({ children }) => {features.nostr && } + {features.nostr && } diff --git a/src/features/ui/util/async-components.ts b/src/features/ui/util/async-components.ts index 65403ecdd..9ae8381f4 100644 --- a/src/features/ui/util/async-components.ts +++ b/src/features/ui/util/async-components.ts @@ -182,4 +182,5 @@ export const CaptchaModal = lazy(() => import('soapbox/features/ui/components/mo export const NostrBunkerLogin = lazy(() => import('soapbox/features/nostr/nostr-bunker-login.tsx')); export const StreakModal = lazy(() => import('soapbox/features/ui/components/modals/streak-modal.tsx')); export const FollowsTimeline = lazy(() => import('soapbox/features/home-timeline/follows-timeline.tsx')); -export const CommunityTimeline = lazy(() => import('soapbox/features/home-timeline/community-timeline.tsx')); \ No newline at end of file +export const CommunityTimeline = lazy(() => import('soapbox/features/home-timeline/community-timeline.tsx')); +export const PolicyManager = lazy(() => import('soapbox/features/admin/policy-manager.tsx')); diff --git a/src/locales/en.json b/src/locales/en.json index 0b5b650ed..0221444af 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -148,6 +148,27 @@ "admin.edit_rule.updated": "Rule edited", "admin.latest_accounts_panel.title": "Latest Accounts", "admin.moderation_log.empty_message": "You have not performed any moderation actions yet. When you do, a history will be shown here.", + "admin.policies.add_value": "Add", + "admin.policies.heading": "Manage Policies", + "admin.policies.help.button": "Help", + "admin.policies.help.description": "Policy Manager allows you to configure moderation and content policies for your instance.", + "admin.policies.help.okay": "Okay", + "admin.policies.help.step1": "1. Use the search bar to find policies you want to add", + "admin.policies.help.step2": "2. Configure each policy with your desired settings", + "admin.policies.help.step3": "3. Click Save to apply the changes", + "admin.policies.help.tip": "Tip: You can add multiple policies to create a comprehensive moderation strategy", + "admin.policies.help.title": "Help", + "admin.policies.no_policies_configured": "No policies configured! Use the search bar above to get started.", + "admin.policies.policy_exists": "Policy already exists!", + "admin.policies.remove_policy": "Remove policy", + "admin.policies.remove_value": "Remove value", + "admin.policies.search_heading": "I want to...", + "admin.policies.search_label": "Policy search field", + "admin.policies.search_placeholder": "What do you want to do?", + "admin.policies.update_error": "Error updating policies: {error}", + "admin.policies.update_success": "Policies saved successfully.", + "admin.policies.welcome.get_started": "Get Started", + "admin.policies.welcome.title": "Welcome to Policy Manager", "admin.relays.add.fail": "Failed to follow the instance relay", "admin.relays.add.success": "Instance relay followed", "admin.relays.deleted": "Relay unfollowed", @@ -339,6 +360,7 @@ "column.admin.edit_rule": "Edit rule", "column.admin.moderation_log": "Moderation Log", "column.admin.nostr_relays": "Relays", + "column.admin.policies": "Moderation Policies", "column.admin.relays": "Instance relays", "column.admin.reports": "Reports", "column.admin.reports.menu.moderation_log": "Moderation Log", @@ -1498,6 +1520,7 @@ "signup_panel.subtitle": "Sign up now to discuss what's happening.", "signup_panel.title": "New to {site_title}?", "site_preview.preview": "Preview", + "soapbox.fuzzy_search_input.default_aria_label": "Fuzzy search input field.", "soapbox_config.authenticated_profile_hint": "Users must be logged-in to view replies and media on user profiles.", "soapbox_config.authenticated_profile_label": "Profiles require authentication", "soapbox_config.copyright_footer.meta_fields.label_placeholder": "Copyright footer", diff --git a/src/utils/policies.ts b/src/utils/policies.ts new file mode 100644 index 000000000..4d5eeddab --- /dev/null +++ b/src/utils/policies.ts @@ -0,0 +1,56 @@ +// Define the state type +export type PolicyState = { + policies: PolicySpecItem[]; + fields: Record; +}; + +// Define action types +export type PolicyAction = + | { type: 'ADD_POLICY'; policy: PolicySpecItem } + | { type: 'REMOVE_POLICY'; name: string } + | { type: 'UPDATE_FIELD'; policyName: string; fieldName: string; value: PolicyParam } + | { type: 'ADD_MULTI_VALUE'; policyName: string; fieldName: string; value: string | number } + | { type: 'REMOVE_MULTI_VALUE'; policyName: string; fieldName: string; value: string | number } + | { type: 'INITIALIZE_FIELDS'; fields: Record }; + +type FieldType = 'string' | 'multi_string' | 'number' | 'multi_number' | 'boolean' | 'unknown'; + +export interface FieldItem { + type: FieldType; + description?: string; + optional?: boolean; + default?: PolicyParam; +} + +export interface PolicyItem { + internalName: string; + name: string; + description?: string; + parameters: Record; +} + +type ParamValue = string | number | boolean; +export type PolicyParam = ParamValue | (string | number)[]; +export type PolicyParams = Record; + +export interface PolicySpecItem { + name: string; + params?: PolicyParams; +} + +export interface PolicySpec { + policies: PolicySpecItem[]; +} + +export interface PolicyResponse { + spec: PolicySpec; +} + +export const stringifyDefault = (value: PolicyParam | undefined) => { + if (typeof value === 'undefined') return ''; + if (Array.isArray(value)) { + return `[${value.join(', ')}]`; + } + if (['number', 'string', 'boolean'].includes(typeof value)) return value.toString(); + return ''; +}; diff --git a/tailwind.config.ts b/tailwind.config.ts index d891e30fd..c3dbf75dd 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -174,4 +174,4 @@ const config: Config = { ], }; -export default config; \ No newline at end of file +export default config; diff --git a/yarn.lock b/yarn.lock index e5d5e231e..1418b6b8b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2624,6 +2624,11 @@ resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.39.tgz#e177e699ee1b8c22d23174caaa7422644389509f" integrity sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw== +"@types/fuzzy-search@^2.1.5": + version "2.1.5" + resolved "https://registry.yarnpkg.com/@types/fuzzy-search/-/fuzzy-search-2.1.5.tgz#f697a826c5214274ae36eaa96d47889846ed4374" + integrity sha512-Yw8OsjhVKbKw83LMDOZ9RXc+N+um48DmZYMrz7QChpHkQuygsc5O40oCL7SfvWgpaaviCx2TbNXYUBwhMtBH5w== + "@types/geojson@*": version "7946.0.10" resolved "https://registry.yarnpkg.com/@types/geojson/-/geojson-7946.0.10.tgz#6dfbf5ea17142f7f9a043809f1cd4c448cb68249" @@ -4927,6 +4932,11 @@ functions-have-names@^1.2.3: resolved "https://registry.yarnpkg.com/functions-have-names/-/functions-have-names-1.2.3.tgz#0404fe4ee2ba2f607f0e0ec3c80bae994133b834" integrity sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ== +fuzzy-search@^3.2.1: + version "3.2.1" + resolved "https://registry.yarnpkg.com/fuzzy-search/-/fuzzy-search-3.2.1.tgz#65d5faad6bc633aee86f1898b7788dfe312ac6c9" + integrity sha512-vAcPiyomt1ioKAsAL2uxSABHJ4Ju/e4UeDM+g1OlR0vV4YhLGMNsdLNvZTpEDY4JCSt0E4hASCNM5t2ETtsbyg== + gensync@^1.0.0-beta.2: version "1.0.0-beta.2" resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0"