kopia lustrzana https://gitlab.com/soapbox-pub/soapbox
Merge branch 'admin-dashboard-policy-ui' into 'main'
Policy UI See merge request soapbox-pub/soapbox!3361merge-requests/3361/merge
commit
407d3eaef1
|
@ -46,6 +46,9 @@
|
|||
"typescript": true,
|
||||
"node": true
|
||||
},
|
||||
"import/no-extraneous-dependencies": {
|
||||
"devDependencies": "true"
|
||||
},
|
||||
"polyfills": [
|
||||
"es:all",
|
||||
"fetch",
|
||||
|
@ -338,4 +341,4 @@
|
|||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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 };
|
|
@ -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;
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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;
|
|
@ -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;
|
||||
};
|
|
@ -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;
|
|
@ -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'
|
||||
|
|
|
@ -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 />
|
||||
|
|
|
@ -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'));
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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 '';
|
||||
};
|
|
@ -174,4 +174,4 @@ const config: Config = {
|
|||
],
|
||||
};
|
||||
|
||||
export default config;
|
||||
export default config;
|
||||
|
|
10
yarn.lock
10
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"
|
||||
|
|
Ładowanie…
Reference in New Issue