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,
|
"typescript": true,
|
||||||
"node": true
|
"node": true
|
||||||
},
|
},
|
||||||
|
"import/no-extraneous-dependencies": {
|
||||||
|
"devDependencies": "true"
|
||||||
|
},
|
||||||
"polyfills": [
|
"polyfills": [
|
||||||
"es:all",
|
"es:all",
|
||||||
"fetch",
|
"fetch",
|
||||||
|
@ -338,4 +341,4 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
@ -104,6 +104,7 @@
|
||||||
"es-toolkit": "^1.27.0",
|
"es-toolkit": "^1.27.0",
|
||||||
"eslint-plugin-formatjs": "^5.2.2",
|
"eslint-plugin-formatjs": "^5.2.2",
|
||||||
"exifr": "^7.1.3",
|
"exifr": "^7.1.3",
|
||||||
|
"fuzzy-search": "^3.2.1",
|
||||||
"graphemesplit": "^2.4.4",
|
"graphemesplit": "^2.4.4",
|
||||||
"html-react-parser": "^5.0.0",
|
"html-react-parser": "^5.0.0",
|
||||||
"http-link-header": "^1.0.2",
|
"http-link-header": "^1.0.2",
|
||||||
|
@ -163,6 +164,7 @@
|
||||||
"@testing-library/react": "^14.0.0",
|
"@testing-library/react": "^14.0.0",
|
||||||
"@testing-library/react-hooks": "^8.0.1",
|
"@testing-library/react-hooks": "^8.0.1",
|
||||||
"@testing-library/user-event": "^14.5.1",
|
"@testing-library/user-event": "^14.5.1",
|
||||||
|
"@types/fuzzy-search": "^2.1.5",
|
||||||
"@typescript-eslint/eslint-plugin": "^7.0.0",
|
"@typescript-eslint/eslint-plugin": "^7.0.0",
|
||||||
"@typescript-eslint/parser": "^7.0.0",
|
"@typescript-eslint/parser": "^7.0.0",
|
||||||
"@vitejs/plugin-react-swc": "^3.7.2",
|
"@vitejs/plugin-react-swc": "^3.7.2",
|
||||||
|
|
|
@ -3,4 +3,5 @@ export { useModerationLog } from './useModerationLog.ts';
|
||||||
export { useRelays } from './useRelays.ts';
|
export { useRelays } from './useRelays.ts';
|
||||||
export { useRules } from './useRules.ts';
|
export { useRules } from './useRules.ts';
|
||||||
export { useSuggest } from './useSuggest.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' />}
|
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 && (
|
{features.nostr && (
|
||||||
<ListItem
|
<ListItem
|
||||||
to='/soapbox/admin/zap-split'
|
to='/soapbox/admin/zap-split'
|
||||||
|
|
|
@ -149,6 +149,7 @@ import {
|
||||||
AdminNostrRelays,
|
AdminNostrRelays,
|
||||||
NostrBunkerLogin,
|
NostrBunkerLogin,
|
||||||
ManageDittoServer,
|
ManageDittoServer,
|
||||||
|
PolicyManager,
|
||||||
} from './util/async-components.ts';
|
} from './util/async-components.ts';
|
||||||
import GlobalHotkeys from './util/global-hotkeys.tsx';
|
import GlobalHotkeys from './util/global-hotkeys.tsx';
|
||||||
import { WrappedRoute } from './util/react-router-helpers.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 />}
|
{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/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/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 />}
|
{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/users' staffOnly page={AdminPage} component={UserIndex} content={children} exact />
|
||||||
<WrappedRoute path='/soapbox/admin/theme' staffOnly page={AdminPage} component={ThemeEditor} 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 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 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 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.edit_rule.updated": "Rule edited",
|
||||||
"admin.latest_accounts_panel.title": "Latest Accounts",
|
"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.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.fail": "Failed to follow the instance relay",
|
||||||
"admin.relays.add.success": "Instance relay followed",
|
"admin.relays.add.success": "Instance relay followed",
|
||||||
"admin.relays.deleted": "Relay unfollowed",
|
"admin.relays.deleted": "Relay unfollowed",
|
||||||
|
@ -339,6 +360,7 @@
|
||||||
"column.admin.edit_rule": "Edit rule",
|
"column.admin.edit_rule": "Edit rule",
|
||||||
"column.admin.moderation_log": "Moderation Log",
|
"column.admin.moderation_log": "Moderation Log",
|
||||||
"column.admin.nostr_relays": "Relays",
|
"column.admin.nostr_relays": "Relays",
|
||||||
|
"column.admin.policies": "Moderation Policies",
|
||||||
"column.admin.relays": "Instance relays",
|
"column.admin.relays": "Instance relays",
|
||||||
"column.admin.reports": "Reports",
|
"column.admin.reports": "Reports",
|
||||||
"column.admin.reports.menu.moderation_log": "Moderation Log",
|
"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.subtitle": "Sign up now to discuss what's happening.",
|
||||||
"signup_panel.title": "New to {site_title}?",
|
"signup_panel.title": "New to {site_title}?",
|
||||||
"site_preview.preview": "Preview",
|
"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_hint": "Users must be logged-in to view replies and media on user profiles.",
|
||||||
"soapbox_config.authenticated_profile_label": "Profiles require authentication",
|
"soapbox_config.authenticated_profile_label": "Profiles require authentication",
|
||||||
"soapbox_config.copyright_footer.meta_fields.label_placeholder": "Copyright footer",
|
"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"
|
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.39.tgz#e177e699ee1b8c22d23174caaa7422644389509f"
|
||||||
integrity sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==
|
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@*":
|
"@types/geojson@*":
|
||||||
version "7946.0.10"
|
version "7946.0.10"
|
||||||
resolved "https://registry.yarnpkg.com/@types/geojson/-/geojson-7946.0.10.tgz#6dfbf5ea17142f7f9a043809f1cd4c448cb68249"
|
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"
|
resolved "https://registry.yarnpkg.com/functions-have-names/-/functions-have-names-1.2.3.tgz#0404fe4ee2ba2f607f0e0ec3c80bae994133b834"
|
||||||
integrity sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==
|
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:
|
gensync@^1.0.0-beta.2:
|
||||||
version "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"
|
resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0"
|
||||||
|
|
Ładowanie…
Reference in New Issue