Merge branch 'admin-dashboard-policy-ui' into 'main'

Policy UI

See merge request soapbox-pub/soapbox!3361
merge-requests/3361/merge
Siddharth Singh 2025-04-11 23:57:21 +00:00
commit 407d3eaef1
17 zmienionych plików z 1150 dodań i 4 usunięć

Wyświetl plik

@ -46,6 +46,9 @@
"typescript": true,
"node": true
},
"import/no-extraneous-dependencies": {
"devDependencies": "true"
},
"polyfills": [
"es:all",
"fetch",
@ -338,4 +341,4 @@
}
}
]
}
}

Wyświetl plik

@ -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",

Wyświetl plik

@ -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';
export { useVerify } from './useVerify.ts';
export { useModerationPolicies } from './useModerationPolicies.ts';

Wyświetl plik

@ -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<PolicyItem[]>;
},
});
// 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<PolicyResponse>;
},
});
// 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<PolicyResponse>;
},
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 };

Wyświetl plik

@ -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<T> {
/** 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.
*/
<T extends Record<string, any> | 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<T extends Record<string, any> | string>({
data,
keys,
onSelection,
displayKey,
placeholder = 'Search...',
placeholders,
placeholderChangeInterval = 5000,
searchFn = defaultSearch,
className = '',
baseId,
inputClassName = '',
ariaLabel = messages.defaultAriaLabel,
renderSuggestion: FuzzySearchSuggestion,
}: FuzzySearchInputProps<T>) {
const intl = useIntl();
const [inputValue, setInputValue] = useState('');
const [suggestions, setSuggestions] = useState<T[]>([]);
const [showSuggestions, setShowSuggestions] = useState(false);
const [activeIndex, setActiveIndex] = useState<number>(-1);
// dynamic placeholder state
const [currentPlaceholder, setCurrentPlaceholder] = useState<string>(
typeof placeholder === 'string' ? placeholder : intl.formatMessage(placeholder),
);
const placeholderIntervalRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const containerRef = useRef<HTMLDivElement>(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<HTMLInputElement>) => {
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<HTMLInputElement>) => {
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 (
<div ref={containerRef} className={`relative ${className}`}>
<Input
id={inputId}
type='text'
value={inputValue}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
onFocus={handleFocus}
onBlur={handleBlur}
placeholder={currentPlaceholder}
autoComplete='off'
aria-label={label}
aria-autocomplete='list'
aria-controls={showSuggestions && suggestions.length > 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 && (
<ul
id={listboxId}
role='listbox'
className='absolute z-10 mt-1 max-h-96 w-full overflow-y-auto rounded-md border border-gray-300 bg-white shadow-lg dark:border-gray-600 dark:bg-gray-800'
>
{suggestions.map((item, index) => {
const isActive = index === activeIndex;
const optionId = getOptionId(index);
return (
<li
key={optionId} // Use unique generated ID as key
id={optionId} // ID for this option
role='option' // Role for each suggestion item
aria-selected={isActive}
onClick={() => 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 ? <FuzzySearchSuggestion item={item} /> : getDisplayText(item)}
</li>
);
})}
</ul>
)}
</div>
);
}
export default FuzzySearchInput;

Wyświetl plik

@ -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<PolicyAction>;
intl: ReturnType<typeof useIntl>;
}> = ({ 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 (
<Card rounded className='relative mx-4 my-1 border-2 border-solid border-gray-700'>
<CardHeader className={Object.keys(def.parameters).length ? 'mb-1' : ''}>
<div className='flex items-center justify-between'>
<strong>{def.name}</strong>
<button
onClick={handleRemovePolicy}
className='ml-2 text-gray-500 hover:text-gray-100'
aria-label={intl.formatMessage(messages.removePolicy)}
>
<Icon src={CloseIcon} className='size-4' />
</button>
</div>
</CardHeader>
<CardBody>
{Object.entries(def.parameters).map(([fieldName, schema]) => (
<PolicyFields
intl={intl}
key={fieldName}
name={fieldName}
schema={schema}
policyName={policy.name}
state={state}
dispatch={dispatch}
/>
))}
</CardBody>
</Card>
);
};

Wyświetl plik

@ -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<typeof useIntl>;
}> = ({ value, onRemove, intl }) => {
return (
<div className='mb-2 mr-2 inline-flex items-center rounded-full bg-gray-200 px-2 py-1 text-gray-800'>
<span className='mr-1 max-w-28 overflow-hidden text-ellipsis' title={String(value)}>{value}</span>
<button onClick={onRemove} className='text-gray-600 hover:text-gray-800' aria-label={intl.formatMessage(messages.removeValue)}>
<Icon src={CloseIcon} className='size-4' />
</button>
</div>
);
};
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<PolicyAction>;
intl: ReturnType<typeof useIntl>;
}> = ({ schema, name, policyName, state, dispatch, intl }) => {
const value = state.fields[`${policyName}.${name}`];
const inputRef = useRef<HTMLInputElement>(null);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
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 (
<Stack space={2}>
<div className='mt-2'>{schema.description}</div>
{
schema.type === 'boolean' ?
<input
type='checkbox'
checked={!!value}
onChange={(e) => {
dispatch({
type: 'UPDATE_FIELD',
policyName,
fieldName: name,
value: e.target.checked,
});
}}
/> :
<Input
type={getInputType(schema.type)}
value={value as string | number}
onChange={handleChange}
placeholder={getInputPlaceholder(schema)}
/>
}
</Stack>
);
}
return (
<Stack space={2}>
<div className='mt-2'>{schema.description}</div>
<Stack space={2}>
<div className='flex items-center'>
<Input
type={getInputType(schema.type)}
placeholder={getInputPlaceholder(schema)}
className='mr-2 flex-1'
ref={inputRef}
/>
<Button className='m-2' onClick={handleAddMultiValue}>
{intl.formatMessage(messages.addValue)}
</Button>
</div>
<div className='flex flex-wrap'>
{((value || []) as (string | number)[]).map((v) => (
<MultiValueBadge
key={v}
intl={intl}
value={v}
onRemove={() => handleRemoveMultiValue(v)}
/>
))}
</div>
</Stack>
</Stack>
);
};

Wyświetl plik

@ -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<PolicyHelpModalProps> = ({ title, onClose, confirmText }) => {
const intl = useIntl();
return (
<div className='fixed inset-0 z-50 flex items-center justify-center bg-gray-800/80'>
<div className='w-auto max-w-2xl'>
<Modal
title={title}
confirmationAction={onClose}
confirmationText={confirmText}
width='md'
>
<div className='space-y-4'>
<p className='text-base'>
{intl.formatMessage(messages.welcomeDescription)}
</p>
<div className='space-y-2 rounded-lg bg-gray-100 p-4 dark:bg-gray-800'>
<p className='font-semibold'>
{intl.formatMessage(messages.welcomeStep1)}
</p>
<p className='font-semibold'>
{intl.formatMessage(messages.welcomeStep2)}
</p>
<p className='font-semibold'>
{intl.formatMessage(messages.welcomeStep3)}
</p>
</div>
<div className='rounded-lg bg-blue-50 p-4 text-blue-700 dark:bg-blue-900/30 dark:text-blue-200'>
{intl.formatMessage(messages.welcomeTip)}
</div>
</div>
</Modal>
</div>
</div>
);
};
export default PolicyHelpModal;

Wyświetl plik

@ -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<string, PolicyParam>) => {
const policyReducer = useMemo(() => createPolicyReducer(allPolicies), [allPolicies]);
const [state, dispatch] = useReducer(policyReducer, { policies: initialPolicies, fields: initialFields });
const prevInitialFields = useRef<Record<string, PolicyParam>>();
useEffect(() => {
if (initialFields && !isEqual(prevInitialFields.current, initialFields)) {
dispatch({ type: 'INITIALIZE_FIELDS', fields: initialFields });
prevInitialFields.current = initialFields;
}
}, [initialFields]);
return [state, dispatch] as const;
};

Wyświetl plik

@ -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 (
<Stack className='p-2'>
<div><strong>{item.name}</strong></div>
<div>{item.description}</div>
</Stack>
);
};
const PolicyManager: FC = () => {
const intl = useIntl();
const [showHelpModal, setShowHelpModal] = useState<boolean>(false);
const [isWelcomeDialog, setIsWelcomeDialog] = useState<boolean>(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<string, PolicyItem>);
const fields: Record<string, PolicyParam> = {};
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<Record<string, PolicyParam>>();
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 (
<Column label={intl.formatMessage(messages.heading)}>
<Spinner size={40} />
</Column>
);
const renderPolicies = () => {
if (state.policies.length === 0) {
// Only show "no policies" message when we're certain data has loaded
if (!isLoading && isFetched) {
return (
<Card size='lg'>
{intl.formatMessage(messages.noPolicyConfigured)}
</Card>
);
} else if (allPoliciesIsError) {
return (
<Card size='lg'>
{allPoliciesError?.message}
</Card>
);
} else if (storedPoliciesIsError) {
return (
<Card size='lg'>
{storedPoliciesError?.message}
</Card>
);
} else {
// If we're not loading but data isn't fetched yet, this prevents flashing "no policies"
return (
<Column label={intl.formatMessage(messages.heading)}>
<Spinner size={40} />
</Column>
);
}
}
return (
<Stack>
{state.policies.map(policy => (
<Policy
intl={intl}
key={policy.name}
policy={policy}
registry={allPolicies}
state={state}
dispatch={dispatch}
/>
))}
</Stack>
);
};
const handleSave = async () => {
const policyParams = Object.entries(state.fields)
.map(([key, value]) => {
const [policy, paramName] = key.split('.');
return { policy, paramName, value };
});
const policies: Record<string, PolicyParams> = {};
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 (
<Column className='mb-8' label={intl.formatMessage(messages.heading)}>
{showHelpModal && (
<PolicyHelpModal
title={isWelcomeDialog
? intl.formatMessage(messages.welcomeTitle)
: intl.formatMessage(messages.helpTitle)
}
confirmText={isWelcomeDialog
? intl.formatMessage(messages.welcomeGetStarted)
: intl.formatMessage(messages.okay)
}
onClose={handleHelpClose}
/>
)}
<div className='p-4'>
<div className='mb-2 flex w-full items-center justify-between'>
<div className='text-lg font-medium'>
{intl.formatMessage(messages.searchHeading)}
</div>
<button
onClick={showHelp}
className='text-primary-600 hover:underline dark:text-accent-blue'
aria-label={intl.formatMessage(messages.helpButton)}
>
{intl.formatMessage(messages.helpButton)}
</button>
</div>
<FuzzySearchInput<PolicyItem>
data={allPolicies}
keys={['name', 'description']}
onSelection={handleSelection}
displayKey='name'
placeholders={dynamicPlaceholders}
ariaLabel={messages.searchFieldLabel}
className='w-full'
renderSuggestion={PolicySuggestion}
/>
</div>
{renderPolicies()}
<div className='m-2 flex w-full flex-row items-center justify-end px-6'>
<Button onClick={handleSave} text='Save' theme='primary' />
<div className={isUpdating ? 'ml-2' : ''}>{isUpdating && <Spinner size={20} withText={false} />}</div>
</div>
</Column>
);
};
export default PolicyManager;

Wyświetl plik

@ -108,6 +108,11 @@ const Dashboard: React.FC = () => {
label={<FormattedMessage id='column.admin.moderation_log' defaultMessage='Moderation Log' />}
/>
<ListItem
to='/soapbox/admin/policies'
label={<FormattedMessage id='column.admin.policies' defaultMessage='Moderation Policies' />}
/>
{features.nostr && (
<ListItem
to='/soapbox/admin/zap-split'

Wyświetl plik

@ -149,6 +149,7 @@ import {
AdminNostrRelays,
NostrBunkerLogin,
ManageDittoServer,
PolicyManager,
} from './util/async-components.ts';
import GlobalHotkeys from './util/global-hotkeys.tsx';
import { WrappedRoute } from './util/react-router-helpers.tsx';
@ -337,6 +338,7 @@ const SwitchingColumnsArea: React.FC<ISwitchingColumnsArea> = ({ children }) =>
{features.nostr && <WrappedRoute path='/soapbox/admin/ditto-server' adminOnly page={WidePage} component={ManageDittoServer} content={children} exact />}
<WrappedRoute path='/soapbox/admin/reports' staffOnly page={AdminPage} component={Dashboard} content={children} exact />
<WrappedRoute path='/soapbox/admin/log' staffOnly page={AdminPage} component={ModerationLog} content={children} exact />
<WrappedRoute path='/soapbox/admin/policies' staffOnly page={AdminPage} component={PolicyManager} content={children} exact />
{features.nostr && <WrappedRoute path='/soapbox/admin/zap-split' staffOnly page={WidePage} component={ManageZapSplit} content={children} exact />}
<WrappedRoute path='/soapbox/admin/users' staffOnly page={AdminPage} component={UserIndex} content={children} exact />
<WrappedRoute path='/soapbox/admin/theme' staffOnly page={AdminPage} component={ThemeEditor} content={children} exact />

Wyświetl plik

@ -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'));
export const CommunityTimeline = lazy(() => import('soapbox/features/home-timeline/community-timeline.tsx'));
export const PolicyManager = lazy(() => import('soapbox/features/admin/policy-manager.tsx'));

Wyświetl plik

@ -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",

Wyświetl plik

@ -0,0 +1,56 @@
// Define the state type
export type PolicyState = {
policies: PolicySpecItem[];
fields: Record<string, PolicyParam>;
};
// 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<string, PolicyParam> };
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<string, FieldItem>;
}
type ParamValue = string | number | boolean;
export type PolicyParam = ParamValue | (string | number)[];
export type PolicyParams = Record<string, PolicyParam>;
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 '';
};

Wyświetl plik

@ -174,4 +174,4 @@ const config: Config = {
],
};
export default config;
export default config;

Wyświetl plik

@ -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"