Merge branch 'moderation-modal' into 'develop'
Moderation modal Closes #1082 See merge request soapbox-pub/soapbox!1788
This commit is contained in:
commit
f9858841fd
|
@ -1,5 +1,6 @@
|
||||||
import { fetchRelationships } from 'soapbox/actions/accounts';
|
import { fetchRelationships } from 'soapbox/actions/accounts';
|
||||||
import { importFetchedAccount, importFetchedAccounts, importFetchedStatuses } from 'soapbox/actions/importer';
|
import { importFetchedAccount, importFetchedAccounts, importFetchedStatuses } from 'soapbox/actions/importer';
|
||||||
|
import { filterBadges, getTagDiff } from 'soapbox/utils/badges';
|
||||||
import { getFeatures } from 'soapbox/utils/features';
|
import { getFeatures } from 'soapbox/utils/features';
|
||||||
|
|
||||||
import api, { getLinks } from '../api';
|
import api, { getLinks } from '../api';
|
||||||
|
@ -403,6 +404,12 @@ const tagUsers = (accountIds: string[], tags: string[]) =>
|
||||||
const untagUsers = (accountIds: string[], tags: string[]) =>
|
const untagUsers = (accountIds: string[], tags: string[]) =>
|
||||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||||
const nicknames = nicknamesFromIds(getState, accountIds);
|
const nicknames = nicknamesFromIds(getState, accountIds);
|
||||||
|
|
||||||
|
// Legacy: allow removing legacy 'donor' tags.
|
||||||
|
if (tags.includes('badge:donor')) {
|
||||||
|
tags = [...tags, 'donor'];
|
||||||
|
}
|
||||||
|
|
||||||
dispatch({ type: ADMIN_USERS_UNTAG_REQUEST, accountIds, tags });
|
dispatch({ type: ADMIN_USERS_UNTAG_REQUEST, accountIds, tags });
|
||||||
return api(getState)
|
return api(getState)
|
||||||
.delete('/api/v1/pleroma/admin/users/tag', { data: { nicknames, tags } })
|
.delete('/api/v1/pleroma/admin/users/tag', { data: { nicknames, tags } })
|
||||||
|
@ -413,6 +420,24 @@ const untagUsers = (accountIds: string[], tags: string[]) =>
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Synchronizes user tags to the backend. */
|
||||||
|
const setTags = (accountId: string, oldTags: string[], newTags: string[]) =>
|
||||||
|
async(dispatch: AppDispatch) => {
|
||||||
|
const diff = getTagDiff(oldTags, newTags);
|
||||||
|
|
||||||
|
await dispatch(tagUsers([accountId], diff.added));
|
||||||
|
await dispatch(untagUsers([accountId], diff.removed));
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Synchronizes badges to the backend. */
|
||||||
|
const setBadges = (accountId: string, oldTags: string[], newTags: string[]) =>
|
||||||
|
(dispatch: AppDispatch) => {
|
||||||
|
const oldBadges = filterBadges(oldTags);
|
||||||
|
const newBadges = filterBadges(newTags);
|
||||||
|
|
||||||
|
return dispatch(setTags(accountId, oldBadges, newBadges));
|
||||||
|
};
|
||||||
|
|
||||||
const verifyUser = (accountId: string) =>
|
const verifyUser = (accountId: string) =>
|
||||||
(dispatch: AppDispatch) =>
|
(dispatch: AppDispatch) =>
|
||||||
dispatch(tagUsers([accountId], ['verified']));
|
dispatch(tagUsers([accountId], ['verified']));
|
||||||
|
@ -421,14 +446,6 @@ const unverifyUser = (accountId: string) =>
|
||||||
(dispatch: AppDispatch) =>
|
(dispatch: AppDispatch) =>
|
||||||
dispatch(untagUsers([accountId], ['verified']));
|
dispatch(untagUsers([accountId], ['verified']));
|
||||||
|
|
||||||
const setDonor = (accountId: string) =>
|
|
||||||
(dispatch: AppDispatch) =>
|
|
||||||
dispatch(tagUsers([accountId], ['donor']));
|
|
||||||
|
|
||||||
const removeDonor = (accountId: string) =>
|
|
||||||
(dispatch: AppDispatch) =>
|
|
||||||
dispatch(untagUsers([accountId], ['donor']));
|
|
||||||
|
|
||||||
const addPermission = (accountIds: string[], permissionGroup: string) =>
|
const addPermission = (accountIds: string[], permissionGroup: string) =>
|
||||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||||
const nicknames = nicknamesFromIds(getState, accountIds);
|
const nicknames = nicknamesFromIds(getState, accountIds);
|
||||||
|
@ -476,6 +493,18 @@ const demoteToUser = (accountId: string) =>
|
||||||
dispatch(removePermission([accountId], 'moderator')),
|
dispatch(removePermission([accountId], 'moderator')),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const setRole = (accountId: string, role: 'user' | 'moderator' | 'admin') =>
|
||||||
|
(dispatch: AppDispatch) => {
|
||||||
|
switch (role) {
|
||||||
|
case 'user':
|
||||||
|
return dispatch(demoteToUser(accountId));
|
||||||
|
case 'moderator':
|
||||||
|
return dispatch(promoteToModerator(accountId));
|
||||||
|
case 'admin':
|
||||||
|
return dispatch(promoteToAdmin(accountId));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const suggestUsers = (accountIds: string[]) =>
|
const suggestUsers = (accountIds: string[]) =>
|
||||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||||
const nicknames = nicknamesFromIds(getState, accountIds);
|
const nicknames = nicknamesFromIds(getState, accountIds);
|
||||||
|
@ -567,15 +596,16 @@ export {
|
||||||
fetchModerationLog,
|
fetchModerationLog,
|
||||||
tagUsers,
|
tagUsers,
|
||||||
untagUsers,
|
untagUsers,
|
||||||
|
setTags,
|
||||||
|
setBadges,
|
||||||
verifyUser,
|
verifyUser,
|
||||||
unverifyUser,
|
unverifyUser,
|
||||||
setDonor,
|
|
||||||
removeDonor,
|
|
||||||
addPermission,
|
addPermission,
|
||||||
removePermission,
|
removePermission,
|
||||||
promoteToAdmin,
|
promoteToAdmin,
|
||||||
promoteToModerator,
|
promoteToModerator,
|
||||||
demoteToUser,
|
demoteToUser,
|
||||||
|
setRole,
|
||||||
suggestUsers,
|
suggestUsers,
|
||||||
unsuggestUsers,
|
unsuggestUsers,
|
||||||
};
|
};
|
||||||
|
|
|
@ -3,24 +3,27 @@ import React from 'react';
|
||||||
|
|
||||||
interface IBadge {
|
interface IBadge {
|
||||||
title: React.ReactNode,
|
title: React.ReactNode,
|
||||||
slug: 'patron' | 'donor' | 'admin' | 'moderator' | 'bot' | 'opaque',
|
slug: string,
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Badge to display on a user's profile. */
|
/** Badge to display on a user's profile. */
|
||||||
const Badge: React.FC<IBadge> = ({ title, slug }) => (
|
const Badge: React.FC<IBadge> = ({ title, slug }) => {
|
||||||
|
const fallback = !['patron', 'admin', 'moderator', 'opaque', 'badge:donor'].includes(slug);
|
||||||
|
|
||||||
|
return (
|
||||||
<span
|
<span
|
||||||
data-testid='badge'
|
data-testid='badge'
|
||||||
className={classNames('inline-flex items-center px-2 py-0.5 rounded text-xs font-medium', {
|
className={classNames('inline-flex items-center px-2 py-0.5 rounded text-xs font-medium', {
|
||||||
'bg-fuchsia-700 text-white': slug === 'patron',
|
'bg-fuchsia-700 text-white': slug === 'patron',
|
||||||
'bg-yellow-500 text-white': slug === 'donor',
|
'bg-emerald-800 text-white': slug === 'badge:donor',
|
||||||
'bg-black text-white': slug === 'admin',
|
'bg-black text-white': slug === 'admin',
|
||||||
'bg-cyan-600 text-white': slug === 'moderator',
|
'bg-cyan-600 text-white': slug === 'moderator',
|
||||||
'bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100': slug === 'bot',
|
'bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100': fallback,
|
||||||
'bg-white bg-opacity-75 text-gray-900': slug === 'opaque',
|
'bg-white bg-opacity-75 text-gray-900': slug === 'opaque',
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default Badge;
|
export default Badge;
|
||||||
|
|
|
@ -34,7 +34,7 @@ const ListItem: React.FC<IListItem> = ({ label, hint, children, onClick }) => {
|
||||||
id: domId,
|
id: domId,
|
||||||
className: classNames({
|
className: classNames({
|
||||||
'w-auto': isSelect,
|
'w-auto': isSelect,
|
||||||
}),
|
}, child.props.className),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -37,10 +37,6 @@ const getBadges = (account: Account): JSX.Element[] => {
|
||||||
badges.push(<Badge key='patron' slug='patron' title='Patron' />);
|
badges.push(<Badge key='patron' slug='patron' title='Patron' />);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (account.donor) {
|
|
||||||
badges.push(<Badge key='donor' slug='donor' title='Donor' />);
|
|
||||||
}
|
|
||||||
|
|
||||||
return badges;
|
return badges;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -10,7 +10,7 @@ import { launchChat } from 'soapbox/actions/chats';
|
||||||
import { directCompose, mentionCompose, quoteCompose, replyCompose } from 'soapbox/actions/compose';
|
import { directCompose, mentionCompose, quoteCompose, replyCompose } from 'soapbox/actions/compose';
|
||||||
import { toggleBookmark, toggleFavourite, togglePin, toggleReblog } from 'soapbox/actions/interactions';
|
import { toggleBookmark, toggleFavourite, togglePin, toggleReblog } from 'soapbox/actions/interactions';
|
||||||
import { openModal } from 'soapbox/actions/modals';
|
import { openModal } from 'soapbox/actions/modals';
|
||||||
import { deactivateUserModal, deleteStatusModal, deleteUserModal, toggleStatusSensitivityModal } from 'soapbox/actions/moderation';
|
import { deleteStatusModal, toggleStatusSensitivityModal } from 'soapbox/actions/moderation';
|
||||||
import { initMuteModal } from 'soapbox/actions/mutes';
|
import { initMuteModal } from 'soapbox/actions/mutes';
|
||||||
import { initReport } from 'soapbox/actions/reports';
|
import { initReport } from 'soapbox/actions/reports';
|
||||||
import { deleteStatus, editStatus, toggleMuteStatus } from 'soapbox/actions/statuses';
|
import { deleteStatus, editStatus, toggleMuteStatus } from 'soapbox/actions/statuses';
|
||||||
|
@ -51,7 +51,7 @@ const messages = defineMessages({
|
||||||
pin: { id: 'status.pin', defaultMessage: 'Pin on profile' },
|
pin: { id: 'status.pin', defaultMessage: 'Pin on profile' },
|
||||||
unpin: { id: 'status.unpin', defaultMessage: 'Unpin from profile' },
|
unpin: { id: 'status.unpin', defaultMessage: 'Unpin from profile' },
|
||||||
embed: { id: 'status.embed', defaultMessage: 'Embed' },
|
embed: { id: 'status.embed', defaultMessage: 'Embed' },
|
||||||
admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' },
|
adminAccount: { id: 'status.admin_account', defaultMessage: 'Moderate @{name}' },
|
||||||
admin_status: { id: 'status.admin_status', defaultMessage: 'Open this post in the moderation interface' },
|
admin_status: { id: 'status.admin_status', defaultMessage: 'Open this post in the moderation interface' },
|
||||||
copy: { id: 'status.copy', defaultMessage: 'Copy link to post' },
|
copy: { id: 'status.copy', defaultMessage: 'Copy link to post' },
|
||||||
group_remove_account: { id: 'status.remove_account_from_group', defaultMessage: 'Remove account from group' },
|
group_remove_account: { id: 'status.remove_account_from_group', defaultMessage: 'Remove account from group' },
|
||||||
|
@ -321,14 +321,10 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeactivateUser: React.EventHandler<React.MouseEvent> = (e) => {
|
const onModerate: React.MouseEventHandler = (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
dispatch(deactivateUserModal(intl, status.getIn(['account', 'id']) as string));
|
const account = status.account as Account;
|
||||||
};
|
dispatch(openModal('ACCOUNT_MODERATION', { accountId: account.id }));
|
||||||
|
|
||||||
const handleDeleteUser: React.EventHandler<React.MouseEvent> = (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
dispatch(deleteUserModal(intl, status.getIn(['account', 'id']) as string));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteStatus: React.EventHandler<React.MouseEvent> = (e) => {
|
const handleDeleteStatus: React.EventHandler<React.MouseEvent> = (e) => {
|
||||||
|
@ -474,13 +470,13 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
|
||||||
if (isStaff) {
|
if (isStaff) {
|
||||||
menu.push(null);
|
menu.push(null);
|
||||||
|
|
||||||
if (isAdmin) {
|
|
||||||
menu.push({
|
menu.push({
|
||||||
text: intl.formatMessage(messages.admin_account, { name: username }),
|
text: intl.formatMessage(messages.adminAccount, { name: username }),
|
||||||
href: `/pleroma/admin/#/users/${status.getIn(['account', 'id'])}/`,
|
action: onModerate,
|
||||||
icon: require('@tabler/icons/gavel.svg'),
|
icon: require('@tabler/icons/gavel.svg'),
|
||||||
action: (event) => event.stopPropagation(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (isAdmin) {
|
||||||
menu.push({
|
menu.push({
|
||||||
text: intl.formatMessage(messages.admin_status),
|
text: intl.formatMessage(messages.admin_status),
|
||||||
href: `/pleroma/admin/#/statuses/${status.id}/`,
|
href: `/pleroma/admin/#/statuses/${status.id}/`,
|
||||||
|
@ -496,17 +492,6 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!ownAccount) {
|
if (!ownAccount) {
|
||||||
menu.push({
|
|
||||||
text: intl.formatMessage(messages.deactivateUser, { name: username }),
|
|
||||||
action: handleDeactivateUser,
|
|
||||||
icon: require('@tabler/icons/user-off.svg'),
|
|
||||||
});
|
|
||||||
menu.push({
|
|
||||||
text: intl.formatMessage(messages.deleteUser, { name: username }),
|
|
||||||
action: handleDeleteUser,
|
|
||||||
icon: require('@tabler/icons/user-minus.svg'),
|
|
||||||
destructive: true,
|
|
||||||
});
|
|
||||||
menu.push({
|
menu.push({
|
||||||
text: intl.formatMessage(messages.deleteStatus),
|
text: intl.formatMessage(messages.deleteStatus),
|
||||||
action: handleDeleteStatus,
|
action: handleDeleteStatus,
|
||||||
|
|
|
@ -34,6 +34,7 @@ export { default as Select } from './select/select';
|
||||||
export { default as Spinner } from './spinner/spinner';
|
export { default as Spinner } from './spinner/spinner';
|
||||||
export { default as Stack } from './stack/stack';
|
export { default as Stack } from './stack/stack';
|
||||||
export { default as Tabs } from './tabs/tabs';
|
export { default as Tabs } from './tabs/tabs';
|
||||||
|
export { default as TagInput } from './tag-input/tag-input';
|
||||||
export { default as Text } from './text/text';
|
export { default as Text } from './text/text';
|
||||||
export { default as Textarea } from './textarea/textarea';
|
export { default as Textarea } from './textarea/textarea';
|
||||||
export { default as Toggle } from './toggle/toggle';
|
export { default as Toggle } from './toggle/toggle';
|
||||||
|
|
|
@ -0,0 +1,70 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
|
import HStack from '../hstack/hstack';
|
||||||
|
|
||||||
|
import Tag from './tag';
|
||||||
|
|
||||||
|
interface ITagInput {
|
||||||
|
tags: string[],
|
||||||
|
onChange: (tags: string[]) => void,
|
||||||
|
placeholder?: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Manage a list of tags. */
|
||||||
|
// https://blog.logrocket.com/building-a-tag-input-field-component-for-react/
|
||||||
|
const TagInput: React.FC<ITagInput> = ({ tags, onChange, placeholder }) => {
|
||||||
|
const [input, setInput] = useState('');
|
||||||
|
|
||||||
|
const handleTagDelete = (tag: string) => {
|
||||||
|
onChange(tags.filter(item => item !== tag));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown: React.KeyboardEventHandler = (e) => {
|
||||||
|
const { key } = e;
|
||||||
|
const trimmedInput = input.trim();
|
||||||
|
|
||||||
|
if (key === 'Tab') {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ([',', 'Tab', 'Enter'].includes(key) && trimmedInput.length && !tags.includes(trimmedInput)) {
|
||||||
|
e.preventDefault();
|
||||||
|
onChange([...tags, trimmedInput]);
|
||||||
|
setInput('');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === 'Backspace' && !input.length && tags.length) {
|
||||||
|
e.preventDefault();
|
||||||
|
const tagsCopy = [...tags];
|
||||||
|
tagsCopy.pop();
|
||||||
|
|
||||||
|
onChange(tagsCopy);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='mt-1 relative shadow-sm flex-grow'>
|
||||||
|
<HStack
|
||||||
|
className='p-2 pb-0 text-gray-900 dark:text-gray-100 placeholder:text-gray-600 dark:placeholder:text-gray-600 block w-full sm:text-sm dark:ring-1 dark:ring-gray-800 focus:ring-primary-500 focus:border-primary-500 dark:focus:ring-primary-500 dark:focus:border-primary-500 rounded-md bg-white dark:bg-gray-900 border-gray-400 dark:border-gray-800'
|
||||||
|
space={2}
|
||||||
|
wrap
|
||||||
|
>
|
||||||
|
{tags.map((tag, i) => (
|
||||||
|
<div className='mb-2'>
|
||||||
|
<Tag tag={tag} onDelete={handleTagDelete} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<input
|
||||||
|
className='p-1 mb-2 w-32 h-8 flex-grow bg-transparent outline-none'
|
||||||
|
value={input}
|
||||||
|
placeholder={placeholder}
|
||||||
|
onChange={e => setInput(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TagInput;
|
|
@ -0,0 +1,29 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import IconButton from '../icon-button/icon-button';
|
||||||
|
import Text from '../text/text';
|
||||||
|
|
||||||
|
interface ITag {
|
||||||
|
/** Name of the tag. */
|
||||||
|
tag: string,
|
||||||
|
/** Callback when the X icon is pressed. */
|
||||||
|
onDelete: (tag: string) => void,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A single editable Tag (used by TagInput). */
|
||||||
|
const Tag: React.FC<ITag> = ({ tag, onDelete }) => {
|
||||||
|
return (
|
||||||
|
<div className='inline-flex p-1 rounded bg-primary-500 items-center whitespace-nowrap'>
|
||||||
|
<Text theme='white'>{tag}</Text>
|
||||||
|
|
||||||
|
<IconButton
|
||||||
|
iconClassName='w-4 h-4'
|
||||||
|
src={require('@tabler/icons/x.svg')}
|
||||||
|
onClick={() => onDelete(tag)}
|
||||||
|
transparent
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Tag;
|
|
@ -6,12 +6,10 @@ import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||||
import { Link, useHistory } from 'react-router-dom';
|
import { Link, useHistory } from 'react-router-dom';
|
||||||
|
|
||||||
import { blockAccount, followAccount, pinAccount, removeFromFollowers, unblockAccount, unmuteAccount, unpinAccount } from 'soapbox/actions/accounts';
|
import { blockAccount, followAccount, pinAccount, removeFromFollowers, unblockAccount, unmuteAccount, unpinAccount } from 'soapbox/actions/accounts';
|
||||||
import { verifyUser, unverifyUser, setDonor, removeDonor, promoteToAdmin, promoteToModerator, demoteToUser, suggestUsers, unsuggestUsers } from 'soapbox/actions/admin';
|
|
||||||
import { launchChat } from 'soapbox/actions/chats';
|
import { launchChat } from 'soapbox/actions/chats';
|
||||||
import { mentionCompose, directCompose } from 'soapbox/actions/compose';
|
import { mentionCompose, directCompose } from 'soapbox/actions/compose';
|
||||||
import { blockDomain, unblockDomain } from 'soapbox/actions/domain_blocks';
|
import { blockDomain, unblockDomain } from 'soapbox/actions/domain_blocks';
|
||||||
import { openModal } from 'soapbox/actions/modals';
|
import { openModal } from 'soapbox/actions/modals';
|
||||||
import { deactivateUserModal } from 'soapbox/actions/moderation';
|
|
||||||
import { initMuteModal } from 'soapbox/actions/mutes';
|
import { initMuteModal } from 'soapbox/actions/mutes';
|
||||||
import { initReport } from 'soapbox/actions/reports';
|
import { initReport } from 'soapbox/actions/reports';
|
||||||
import { setSearchAccount } from 'soapbox/actions/search';
|
import { setSearchAccount } from 'soapbox/actions/search';
|
||||||
|
@ -26,10 +24,7 @@ import ActionButton from 'soapbox/features/ui/components/action-button';
|
||||||
import SubscriptionButton from 'soapbox/features/ui/components/subscription-button';
|
import SubscriptionButton from 'soapbox/features/ui/components/subscription-button';
|
||||||
import { useAppDispatch, useFeatures, useOwnAccount } from 'soapbox/hooks';
|
import { useAppDispatch, useFeatures, useOwnAccount } from 'soapbox/hooks';
|
||||||
import { Account } from 'soapbox/types/entities';
|
import { Account } from 'soapbox/types/entities';
|
||||||
import {
|
import { isRemote } from 'soapbox/utils/accounts';
|
||||||
isLocal,
|
|
||||||
isRemote,
|
|
||||||
} from 'soapbox/utils/accounts';
|
|
||||||
|
|
||||||
import type { Menu as MenuType } from 'soapbox/components/dropdown_menu';
|
import type { Menu as MenuType } from 'soapbox/components/dropdown_menu';
|
||||||
|
|
||||||
|
@ -59,40 +54,17 @@ const messages = defineMessages({
|
||||||
endorse: { id: 'account.endorse', defaultMessage: 'Feature on profile' },
|
endorse: { id: 'account.endorse', defaultMessage: 'Feature on profile' },
|
||||||
unendorse: { id: 'account.unendorse', defaultMessage: 'Don\'t feature on profile' },
|
unendorse: { id: 'account.unendorse', defaultMessage: 'Don\'t feature on profile' },
|
||||||
removeFromFollowers: { id: 'account.remove_from_followers', defaultMessage: 'Remove this follower' },
|
removeFromFollowers: { id: 'account.remove_from_followers', defaultMessage: 'Remove this follower' },
|
||||||
admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' },
|
adminAccount: { id: 'status.admin_account', defaultMessage: 'Moderate @{name}' },
|
||||||
add_or_remove_from_list: { id: 'account.add_or_remove_from_list', defaultMessage: 'Add or Remove from lists' },
|
add_or_remove_from_list: { id: 'account.add_or_remove_from_list', defaultMessage: 'Add or Remove from lists' },
|
||||||
deactivateUser: { id: 'admin.users.actions.deactivate_user', defaultMessage: 'Deactivate @{name}' },
|
|
||||||
deleteUser: { id: 'admin.users.actions.delete_user', defaultMessage: 'Delete @{name}' },
|
|
||||||
verifyUser: { id: 'admin.users.actions.verify_user', defaultMessage: 'Verify @{name}' },
|
|
||||||
unverifyUser: { id: 'admin.users.actions.unverify_user', defaultMessage: 'Unverify @{name}' },
|
|
||||||
setDonor: { id: 'admin.users.actions.set_donor', defaultMessage: 'Set @{name} as a donor' },
|
|
||||||
removeDonor: { id: 'admin.users.actions.remove_donor', defaultMessage: 'Remove @{name} as a donor' },
|
|
||||||
promoteToAdmin: { id: 'admin.users.actions.promote_to_admin', defaultMessage: 'Promote @{name} to an admin' },
|
|
||||||
promoteToModerator: { id: 'admin.users.actions.promote_to_moderator', defaultMessage: 'Promote @{name} to a moderator' },
|
|
||||||
demoteToModerator: { id: 'admin.users.actions.demote_to_moderator', defaultMessage: 'Demote @{name} to a moderator' },
|
|
||||||
demoteToUser: { id: 'admin.users.actions.demote_to_user', defaultMessage: 'Demote @{name} to a regular user' },
|
|
||||||
suggestUser: { id: 'admin.users.actions.suggest_user', defaultMessage: 'Suggest @{name}' },
|
|
||||||
unsuggestUser: { id: 'admin.users.actions.unsuggest_user', defaultMessage: 'Unsuggest @{name}' },
|
|
||||||
search: { id: 'account.search', defaultMessage: 'Search from @{name}' },
|
search: { id: 'account.search', defaultMessage: 'Search from @{name}' },
|
||||||
searchSelf: { id: 'account.search_self', defaultMessage: 'Search your posts' },
|
searchSelf: { id: 'account.search_self', defaultMessage: 'Search your posts' },
|
||||||
unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' },
|
unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' },
|
||||||
blockConfirm: { id: 'confirmations.block.confirm', defaultMessage: 'Block' },
|
blockConfirm: { id: 'confirmations.block.confirm', defaultMessage: 'Block' },
|
||||||
blockDomainConfirm: { id: 'confirmations.domain_block.confirm', defaultMessage: 'Hide entire domain' },
|
blockDomainConfirm: { id: 'confirmations.domain_block.confirm', defaultMessage: 'Hide entire domain' },
|
||||||
blockAndReport: { id: 'confirmations.block.block_and_report', defaultMessage: 'Block & Report' },
|
blockAndReport: { id: 'confirmations.block.block_and_report', defaultMessage: 'Block & Report' },
|
||||||
userVerified: { id: 'admin.users.user_verified_message', defaultMessage: '@{acct} was verified' },
|
|
||||||
userUnverified: { id: 'admin.users.user_unverified_message', defaultMessage: '@{acct} was unverified' },
|
|
||||||
setDonorSuccess: { id: 'admin.users.set_donor_message', defaultMessage: '@{acct} was set as a donor' },
|
|
||||||
removeDonorSuccess: { id: 'admin.users.remove_donor_message', defaultMessage: '@{acct} was removed as a donor' },
|
|
||||||
promotedToAdmin: { id: 'admin.users.actions.promote_to_admin_message', defaultMessage: '@{acct} was promoted to an admin' },
|
|
||||||
promotedToModerator: { id: 'admin.users.actions.promote_to_moderator_message', defaultMessage: '@{acct} was promoted to a moderator' },
|
|
||||||
demotedToModerator: { id: 'admin.users.actions.demote_to_moderator_message', defaultMessage: '@{acct} was demoted to a moderator' },
|
|
||||||
demotedToUser: { id: 'admin.users.actions.demote_to_user_message', defaultMessage: '@{acct} was demoted to a regular user' },
|
|
||||||
userSuggested: { id: 'admin.users.user_suggested_message', defaultMessage: '@{acct} was suggested' },
|
|
||||||
userUnsuggested: { id: 'admin.users.user_unsuggested_message', defaultMessage: '@{acct} was unsuggested' },
|
|
||||||
removeFromFollowersConfirm: { id: 'confirmations.remove_from_followers.confirm', defaultMessage: 'Remove' },
|
removeFromFollowersConfirm: { id: 'confirmations.remove_from_followers.confirm', defaultMessage: 'Remove' },
|
||||||
userEndorsed: { id: 'account.endorse.success', defaultMessage: 'You are now featuring @{acct} on your profile' },
|
userEndorsed: { id: 'account.endorse.success', defaultMessage: 'You are now featuring @{acct} on your profile' },
|
||||||
userUnendorsed: { id: 'account.unendorse.success', defaultMessage: 'You are no longer featuring @{acct}' },
|
userUnendorsed: { id: 'account.unendorse.success', defaultMessage: 'You are no longer featuring @{acct}' },
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
interface IHeader {
|
interface IHeader {
|
||||||
|
@ -210,81 +182,8 @@ const Header: React.FC<IHeader> = ({ account }) => {
|
||||||
dispatch(launchChat(account.id, history));
|
dispatch(launchChat(account.id, history));
|
||||||
};
|
};
|
||||||
|
|
||||||
const onDeactivateUser = () => {
|
const onModerate = () => {
|
||||||
dispatch(deactivateUserModal(intl, account.id));
|
dispatch(openModal('ACCOUNT_MODERATION', { accountId: account.id }));
|
||||||
};
|
|
||||||
|
|
||||||
const onVerifyUser = () => {
|
|
||||||
const message = intl.formatMessage(messages.userVerified, { acct: account.acct });
|
|
||||||
|
|
||||||
dispatch(verifyUser(account.id))
|
|
||||||
.then(() => dispatch(snackbar.success(message)))
|
|
||||||
.catch(() => {});
|
|
||||||
};
|
|
||||||
|
|
||||||
const onUnverifyUser = () => {
|
|
||||||
const message = intl.formatMessage(messages.userUnverified, { acct: account.acct });
|
|
||||||
|
|
||||||
dispatch(unverifyUser(account.id))
|
|
||||||
.then(() => dispatch(snackbar.success(message)))
|
|
||||||
.catch(() => {});
|
|
||||||
};
|
|
||||||
|
|
||||||
const onSetDonor = () => {
|
|
||||||
const message = intl.formatMessage(messages.setDonorSuccess, { acct: account.acct });
|
|
||||||
|
|
||||||
dispatch(setDonor(account.id))
|
|
||||||
.then(() => dispatch(snackbar.success(message)))
|
|
||||||
.catch(() => {});
|
|
||||||
};
|
|
||||||
|
|
||||||
const onRemoveDonor = () => {
|
|
||||||
const message = intl.formatMessage(messages.removeDonorSuccess, { acct: account.acct });
|
|
||||||
|
|
||||||
dispatch(removeDonor(account.id))
|
|
||||||
.then(() => dispatch(snackbar.success(message)))
|
|
||||||
.catch(() => {});
|
|
||||||
};
|
|
||||||
|
|
||||||
const onPromoteToAdmin = () => {
|
|
||||||
const message = intl.formatMessage(messages.promotedToAdmin, { acct: account.acct });
|
|
||||||
|
|
||||||
dispatch(promoteToAdmin(account.id))
|
|
||||||
.then(() => dispatch(snackbar.success(message)))
|
|
||||||
.catch(() => {});
|
|
||||||
};
|
|
||||||
|
|
||||||
const onPromoteToModerator = () => {
|
|
||||||
const messageType = account.admin ? messages.demotedToModerator : messages.promotedToModerator;
|
|
||||||
const message = intl.formatMessage(messageType, { acct: account.acct });
|
|
||||||
|
|
||||||
dispatch(promoteToModerator(account.id))
|
|
||||||
.then(() => dispatch(snackbar.success(message)))
|
|
||||||
.catch(() => {});
|
|
||||||
};
|
|
||||||
|
|
||||||
const onDemoteToUser = () => {
|
|
||||||
const message = intl.formatMessage(messages.demotedToUser, { acct: account.acct });
|
|
||||||
|
|
||||||
dispatch(demoteToUser(account.id))
|
|
||||||
.then(() => dispatch(snackbar.success(message)))
|
|
||||||
.catch(() => {});
|
|
||||||
};
|
|
||||||
|
|
||||||
const onSuggestUser = () => {
|
|
||||||
const message = intl.formatMessage(messages.userSuggested, { acct: account.acct });
|
|
||||||
|
|
||||||
dispatch(suggestUsers([account.id]))
|
|
||||||
.then(() => dispatch(snackbar.success(message)))
|
|
||||||
.catch(() => {});
|
|
||||||
};
|
|
||||||
|
|
||||||
const onUnsuggestUser = () => {
|
|
||||||
const message = intl.formatMessage(messages.userUnsuggested, { acct: account.acct });
|
|
||||||
|
|
||||||
dispatch(unsuggestUsers([account.id]))
|
|
||||||
.then(() => dispatch(snackbar.success(message)))
|
|
||||||
.catch(() => {});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const onRemoveFromFollowers = () => {
|
const onRemoveFromFollowers = () => {
|
||||||
|
@ -532,109 +431,13 @@ const Header: React.FC<IHeader> = ({ account }) => {
|
||||||
if (ownAccount?.staff) {
|
if (ownAccount?.staff) {
|
||||||
menu.push(null);
|
menu.push(null);
|
||||||
|
|
||||||
if (ownAccount?.admin) {
|
|
||||||
menu.push({
|
menu.push({
|
||||||
text: intl.formatMessage(messages.admin_account, { name: account.username }),
|
text: intl.formatMessage(messages.adminAccount, { name: account.username }),
|
||||||
to: `/pleroma/admin/#/users/${account.id}/`,
|
action: onModerate,
|
||||||
newTab: true,
|
|
||||||
icon: require('@tabler/icons/gavel.svg'),
|
icon: require('@tabler/icons/gavel.svg'),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (account.id !== ownAccount?.id && isLocal(account) && ownAccount.admin) {
|
|
||||||
if (account.admin) {
|
|
||||||
menu.push({
|
|
||||||
text: intl.formatMessage(messages.demoteToModerator, { name: account.username }),
|
|
||||||
action: onPromoteToModerator,
|
|
||||||
icon: require('@tabler/icons/arrow-up-circle.svg'),
|
|
||||||
});
|
|
||||||
menu.push({
|
|
||||||
text: intl.formatMessage(messages.demoteToUser, { name: account.username }),
|
|
||||||
action: onDemoteToUser,
|
|
||||||
icon: require('@tabler/icons/arrow-down-circle.svg'),
|
|
||||||
});
|
|
||||||
} else if (account.moderator) {
|
|
||||||
menu.push({
|
|
||||||
text: intl.formatMessage(messages.promoteToAdmin, { name: account.username }),
|
|
||||||
action: onPromoteToAdmin,
|
|
||||||
icon: require('@tabler/icons/arrow-up-circle.svg'),
|
|
||||||
});
|
|
||||||
menu.push({
|
|
||||||
text: intl.formatMessage(messages.demoteToUser, { name: account.username }),
|
|
||||||
action: onDemoteToUser,
|
|
||||||
icon: require('@tabler/icons/arrow-down-circle.svg'),
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
menu.push({
|
|
||||||
text: intl.formatMessage(messages.promoteToAdmin, { name: account.username }),
|
|
||||||
action: onPromoteToAdmin,
|
|
||||||
icon: require('@tabler/icons/arrow-up-circle.svg'),
|
|
||||||
});
|
|
||||||
menu.push({
|
|
||||||
text: intl.formatMessage(messages.promoteToModerator, { name: account.username }),
|
|
||||||
action: onPromoteToModerator,
|
|
||||||
icon: require('@tabler/icons/arrow-up-circle.svg'),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (account.verified) {
|
|
||||||
menu.push({
|
|
||||||
text: intl.formatMessage(messages.unverifyUser, { name: account.username }),
|
|
||||||
action: onUnverifyUser,
|
|
||||||
icon: require('@tabler/icons/check.svg'),
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
menu.push({
|
|
||||||
text: intl.formatMessage(messages.verifyUser, { name: account.username }),
|
|
||||||
action: onVerifyUser,
|
|
||||||
icon: require('@tabler/icons/check.svg'),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (account.donor) {
|
|
||||||
menu.push({
|
|
||||||
text: intl.formatMessage(messages.removeDonor, { name: account.username }),
|
|
||||||
action: onRemoveDonor,
|
|
||||||
icon: require('@tabler/icons/coin.svg'),
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
menu.push({
|
|
||||||
text: intl.formatMessage(messages.setDonor, { name: account.username }),
|
|
||||||
action: onSetDonor,
|
|
||||||
icon: require('@tabler/icons/coin.svg'),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (features.suggestionsV2 && ownAccount.admin) {
|
|
||||||
if (account.getIn(['pleroma', 'is_suggested'])) {
|
|
||||||
menu.push({
|
|
||||||
text: intl.formatMessage(messages.unsuggestUser, { name: account.username }),
|
|
||||||
action: onUnsuggestUser,
|
|
||||||
icon: require('@tabler/icons/user-x.svg'),
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
menu.push({
|
|
||||||
text: intl.formatMessage(messages.suggestUser, { name: account.username }),
|
|
||||||
action: onSuggestUser,
|
|
||||||
icon: require('@tabler/icons/user-check.svg'),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (account.id !== ownAccount?.id) {
|
|
||||||
menu.push({
|
|
||||||
text: intl.formatMessage(messages.deactivateUser, { name: account.username }),
|
|
||||||
action: onDeactivateUser,
|
|
||||||
icon: require('@tabler/icons/user-off.svg'),
|
|
||||||
});
|
|
||||||
menu.push({
|
|
||||||
text: intl.formatMessage(messages.deleteUser, { name: account.username }),
|
|
||||||
icon: require('@tabler/icons/user-minus.svg'),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return menu;
|
return menu;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -32,6 +32,7 @@ import {
|
||||||
CompareHistoryModal,
|
CompareHistoryModal,
|
||||||
VerifySmsModal,
|
VerifySmsModal,
|
||||||
FamiliarFollowersModal,
|
FamiliarFollowersModal,
|
||||||
|
AccountModerationModal,
|
||||||
} from 'soapbox/features/ui/util/async-components';
|
} from 'soapbox/features/ui/util/async-components';
|
||||||
|
|
||||||
import BundleContainer from '../containers/bundle_container';
|
import BundleContainer from '../containers/bundle_container';
|
||||||
|
@ -69,6 +70,7 @@ const MODAL_COMPONENTS = {
|
||||||
'COMPARE_HISTORY': CompareHistoryModal,
|
'COMPARE_HISTORY': CompareHistoryModal,
|
||||||
'VERIFY_SMS': VerifySmsModal,
|
'VERIFY_SMS': VerifySmsModal,
|
||||||
'FAMILIAR_FOLLOWERS': FamiliarFollowersModal,
|
'FAMILIAR_FOLLOWERS': FamiliarFollowersModal,
|
||||||
|
'ACCOUNT_MODERATION': AccountModerationModal,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default class ModalRoot extends React.PureComponent {
|
export default class ModalRoot extends React.PureComponent {
|
||||||
|
|
|
@ -0,0 +1,190 @@
|
||||||
|
import React, { ChangeEventHandler, useState } from 'react';
|
||||||
|
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||||
|
|
||||||
|
import {
|
||||||
|
verifyUser,
|
||||||
|
unverifyUser,
|
||||||
|
suggestUsers,
|
||||||
|
unsuggestUsers,
|
||||||
|
setBadges as saveBadges,
|
||||||
|
} from 'soapbox/actions/admin';
|
||||||
|
import { deactivateUserModal, deleteUserModal } from 'soapbox/actions/moderation';
|
||||||
|
import snackbar from 'soapbox/actions/snackbar';
|
||||||
|
import Account from 'soapbox/components/account';
|
||||||
|
import List, { ListItem } from 'soapbox/components/list';
|
||||||
|
import MissingIndicator from 'soapbox/components/missing_indicator';
|
||||||
|
import { Button, Text, HStack, Modal, Stack, Toggle } from 'soapbox/components/ui';
|
||||||
|
import { useAppDispatch, useAppSelector, useFeatures, useOwnAccount } from 'soapbox/hooks';
|
||||||
|
import { makeGetAccount } from 'soapbox/selectors';
|
||||||
|
import { isLocal } from 'soapbox/utils/accounts';
|
||||||
|
import { getBadges } from 'soapbox/utils/badges';
|
||||||
|
|
||||||
|
import BadgeInput from './badge-input';
|
||||||
|
import StaffRolePicker from './staff-role-picker';
|
||||||
|
|
||||||
|
const getAccount = makeGetAccount();
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
userVerified: { id: 'admin.users.user_verified_message', defaultMessage: '@{acct} was verified' },
|
||||||
|
userUnverified: { id: 'admin.users.user_unverified_message', defaultMessage: '@{acct} was unverified' },
|
||||||
|
setDonorSuccess: { id: 'admin.users.set_donor_message', defaultMessage: '@{acct} was set as a donor' },
|
||||||
|
removeDonorSuccess: { id: 'admin.users.remove_donor_message', defaultMessage: '@{acct} was removed as a donor' },
|
||||||
|
userSuggested: { id: 'admin.users.user_suggested_message', defaultMessage: '@{acct} was suggested' },
|
||||||
|
userUnsuggested: { id: 'admin.users.user_unsuggested_message', defaultMessage: '@{acct} was unsuggested' },
|
||||||
|
badgesSaved: { id: 'admin.users.badges_saved_message', defaultMessage: 'Custom badges updated.' },
|
||||||
|
});
|
||||||
|
|
||||||
|
interface IAccountModerationModal {
|
||||||
|
/** Action to close the modal. */
|
||||||
|
onClose: (type: string) => void,
|
||||||
|
/** ID of the account to moderate. */
|
||||||
|
accountId: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Moderator actions against accounts. */
|
||||||
|
const AccountModerationModal: React.FC<IAccountModerationModal> = ({ onClose, accountId }) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const ownAccount = useOwnAccount();
|
||||||
|
const features = useFeatures();
|
||||||
|
const account = useAppSelector(state => getAccount(state, accountId));
|
||||||
|
|
||||||
|
const accountBadges = account ? getBadges(account) : [];
|
||||||
|
const [badges, setBadges] = useState<string[]>(accountBadges);
|
||||||
|
|
||||||
|
const handleClose = () => onClose('ACCOUNT_MODERATION');
|
||||||
|
|
||||||
|
if (!account || !ownAccount) {
|
||||||
|
return (
|
||||||
|
<Modal onClose={handleClose}>
|
||||||
|
<MissingIndicator />
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAdminFE = () => {
|
||||||
|
window.open(`/pleroma/admin/#/users/${account.id}/`, '_blank');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVerifiedChange: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||||
|
const { checked } = e.target;
|
||||||
|
|
||||||
|
const message = checked ? messages.userVerified : messages.userUnverified;
|
||||||
|
const action = checked ? verifyUser : unverifyUser;
|
||||||
|
|
||||||
|
dispatch(action(account.id))
|
||||||
|
.then(() => dispatch(snackbar.success(intl.formatMessage(message, { acct: account.acct }))))
|
||||||
|
.catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSuggestedChange: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||||
|
const { checked } = e.target;
|
||||||
|
|
||||||
|
const message = checked ? messages.userSuggested : messages.userUnsuggested;
|
||||||
|
const action = checked ? suggestUsers : unsuggestUsers;
|
||||||
|
|
||||||
|
dispatch(action([account.id]))
|
||||||
|
.then(() => dispatch(snackbar.success(intl.formatMessage(message, { acct: account.acct }))))
|
||||||
|
.catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeactivate = () => {
|
||||||
|
dispatch(deactivateUserModal(intl, account.id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
dispatch(deleteUserModal(intl, account.id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveBadges = () => {
|
||||||
|
dispatch(saveBadges(account.id, accountBadges, badges))
|
||||||
|
.then(() => dispatch(snackbar.success(intl.formatMessage(messages.badgesSaved))))
|
||||||
|
.catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={<FormattedMessage id='account_moderation_modal.title' defaultMessage='Moderate @{acct}' values={{ acct: account.acct }} />}
|
||||||
|
onClose={handleClose}
|
||||||
|
>
|
||||||
|
<Stack space={4}>
|
||||||
|
<div className='p-4 rounded-lg border border-solid border-gray-300 dark:border-gray-800'>
|
||||||
|
<Account
|
||||||
|
account={account}
|
||||||
|
showProfileHoverCard={false}
|
||||||
|
withLinkToProfile={false}
|
||||||
|
hideActions
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<List>
|
||||||
|
{(ownAccount.admin && isLocal(account)) && (
|
||||||
|
<ListItem label={<FormattedMessage id='account_moderation_modal.fields.account_role' defaultMessage='Staff level' />}>
|
||||||
|
<div className='w-auto'>
|
||||||
|
<StaffRolePicker account={account} />
|
||||||
|
</div>
|
||||||
|
</ListItem>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ListItem label={<FormattedMessage id='account_moderation_modal.fields.verified' defaultMessage='Verified account' />}>
|
||||||
|
<Toggle
|
||||||
|
checked={account.verified}
|
||||||
|
onChange={handleVerifiedChange}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
|
{features.suggestionsV2 && (
|
||||||
|
<ListItem label={<FormattedMessage id='account_moderation_modal.fields.suggested' defaultMessage='Suggested in people to follow' />}>
|
||||||
|
<Toggle
|
||||||
|
checked={account.getIn(['pleroma', 'is_suggested']) === true}
|
||||||
|
onChange={handleSuggestedChange}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ListItem label={<FormattedMessage id='account_moderation_modal.fields.badges' defaultMessage='Custom badges' />}>
|
||||||
|
<div className='flex-grow'>
|
||||||
|
<HStack className='w-full' alignItems='center' space={2}>
|
||||||
|
<BadgeInput badges={badges} onChange={setBadges} />
|
||||||
|
<Button onClick={handleSaveBadges}>
|
||||||
|
<FormattedMessage id='save' defaultMessage='Save' />
|
||||||
|
</Button>
|
||||||
|
</HStack>
|
||||||
|
</div>
|
||||||
|
</ListItem>
|
||||||
|
</List>
|
||||||
|
|
||||||
|
<List>
|
||||||
|
<ListItem
|
||||||
|
label={<FormattedMessage id='account_moderation_modal.fields.deactivate' defaultMessage='Deactivate account' />}
|
||||||
|
onClick={handleDeactivate}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ListItem
|
||||||
|
label={<FormattedMessage id='account_moderation_modal.fields.delete' defaultMessage='Delete account' />}
|
||||||
|
onClick={handleDelete}
|
||||||
|
/>
|
||||||
|
</List>
|
||||||
|
|
||||||
|
<Text theme='subtle' size='xs'>
|
||||||
|
<FormattedMessage
|
||||||
|
id='account_moderation_modal.info.id'
|
||||||
|
defaultMessage='ID: {id}'
|
||||||
|
values={{ id: account.id }}
|
||||||
|
/>
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{features.adminFE && (
|
||||||
|
<HStack justifyContent='center'>
|
||||||
|
<Button icon={require('@tabler/icons/external-link.svg')} size='sm' theme='secondary' onClick={handleAdminFE}>
|
||||||
|
<FormattedMessage id='account_moderation_modal.admin_fe' defaultMessage='Open in AdminFE' />
|
||||||
|
</Button>
|
||||||
|
</HStack>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AccountModerationModal;
|
|
@ -0,0 +1,37 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { useIntl, defineMessages } from 'react-intl';
|
||||||
|
|
||||||
|
import { TagInput } from 'soapbox/components/ui';
|
||||||
|
import { badgeToTag, tagToBadge } from 'soapbox/utils/badges';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
placeholder: { id: 'badge_input.placeholder', defaultMessage: 'Enter a badge…' },
|
||||||
|
});
|
||||||
|
|
||||||
|
interface IBadgeInput {
|
||||||
|
/** A badge is a tag that begins with `badge:` */
|
||||||
|
badges: string[],
|
||||||
|
/** Callback when badges change. */
|
||||||
|
onChange: (badges: string[]) => void,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Manages user badges. */
|
||||||
|
const BadgeInput: React.FC<IBadgeInput> = ({ badges, onChange }) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const tags = badges.map(badgeToTag);
|
||||||
|
|
||||||
|
const handleTagsChange = (tags: string[]) => {
|
||||||
|
const badges = tags.map(tagToBadge);
|
||||||
|
onChange(badges);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TagInput
|
||||||
|
tags={tags}
|
||||||
|
onChange={handleTagsChange}
|
||||||
|
placeholder={intl.formatMessage(messages.placeholder)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BadgeInput;
|
|
@ -0,0 +1,86 @@
|
||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { defineMessages, MessageDescriptor, useIntl } from 'react-intl';
|
||||||
|
|
||||||
|
import { setRole } from 'soapbox/actions/admin';
|
||||||
|
import snackbar from 'soapbox/actions/snackbar';
|
||||||
|
import { SelectDropdown } from 'soapbox/features/forms';
|
||||||
|
import { useAppDispatch } from 'soapbox/hooks';
|
||||||
|
|
||||||
|
import type { Account as AccountEntity } from 'soapbox/types/entities';
|
||||||
|
|
||||||
|
/** Staff role. */
|
||||||
|
type AccountRole = 'user' | 'moderator' | 'admin';
|
||||||
|
|
||||||
|
/** Get the highest staff role associated with the account. */
|
||||||
|
const getRole = (account: AccountEntity): AccountRole => {
|
||||||
|
if (account.admin) {
|
||||||
|
return 'admin';
|
||||||
|
} else if (account.moderator) {
|
||||||
|
return 'moderator';
|
||||||
|
} else {
|
||||||
|
return 'user';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
roleUser: { id: 'account_moderation_modal.roles.user', defaultMessage: 'User' },
|
||||||
|
roleModerator: { id: 'account_moderation_modal.roles.moderator', defaultMessage: 'Moderator' },
|
||||||
|
roleAdmin: { id: 'account_moderation_modal.roles.admin', defaultMessage: 'Admin' },
|
||||||
|
promotedToAdmin: { id: 'admin.users.actions.promote_to_admin_message', defaultMessage: '@{acct} was promoted to an admin' },
|
||||||
|
promotedToModerator: { id: 'admin.users.actions.promote_to_moderator_message', defaultMessage: '@{acct} was promoted to a moderator' },
|
||||||
|
demotedToModerator: { id: 'admin.users.actions.demote_to_moderator_message', defaultMessage: '@{acct} was demoted to a moderator' },
|
||||||
|
demotedToUser: { id: 'admin.users.actions.demote_to_user_message', defaultMessage: '@{acct} was demoted to a regular user' },
|
||||||
|
});
|
||||||
|
|
||||||
|
interface IStaffRolePicker {
|
||||||
|
/** Account whose role to change. */
|
||||||
|
account: AccountEntity,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Picker for setting the staff role of an account. */
|
||||||
|
const StaffRolePicker: React.FC<IStaffRolePicker> = ({ account }) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const roles: Record<AccountRole, string> = useMemo(() => ({
|
||||||
|
user: intl.formatMessage(messages.roleUser),
|
||||||
|
moderator: intl.formatMessage(messages.roleModerator),
|
||||||
|
admin: intl.formatMessage(messages.roleAdmin),
|
||||||
|
}), []);
|
||||||
|
|
||||||
|
const handleRoleChange: React.ChangeEventHandler<HTMLSelectElement> = (e) => {
|
||||||
|
const role = e.target.value as AccountRole;
|
||||||
|
|
||||||
|
dispatch(setRole(account.id, role))
|
||||||
|
.then(() => {
|
||||||
|
let message: MessageDescriptor | undefined;
|
||||||
|
|
||||||
|
if (role === 'admin') {
|
||||||
|
message = messages.promotedToAdmin;
|
||||||
|
} else if (role === 'moderator' && account.admin) {
|
||||||
|
message = messages.demotedToModerator;
|
||||||
|
} else if (role === 'moderator') {
|
||||||
|
message = messages.promotedToModerator;
|
||||||
|
} else if (role === 'user') {
|
||||||
|
message = messages.demotedToUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message) {
|
||||||
|
dispatch(snackbar.success(intl.formatMessage(message, { acct: account.acct })));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
|
const accountRole = getRole(account);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SelectDropdown
|
||||||
|
items={roles}
|
||||||
|
defaultValue={accountRole}
|
||||||
|
onChange={handleRoleChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StaffRolePicker;
|
|
@ -8,6 +8,8 @@ import { Icon, HStack, Stack, Text } from 'soapbox/components/ui';
|
||||||
import VerificationBadge from 'soapbox/components/verification_badge';
|
import VerificationBadge from 'soapbox/components/verification_badge';
|
||||||
import { useSoapboxConfig } from 'soapbox/hooks';
|
import { useSoapboxConfig } from 'soapbox/hooks';
|
||||||
import { isLocal } from 'soapbox/utils/accounts';
|
import { isLocal } from 'soapbox/utils/accounts';
|
||||||
|
import { badgeToTag, getBadges as getAccountBadges } from 'soapbox/utils/badges';
|
||||||
|
import { capitalize } from 'soapbox/utils/strings';
|
||||||
|
|
||||||
import ProfileFamiliarFollowers from './profile_familiar_followers';
|
import ProfileFamiliarFollowers from './profile_familiar_followers';
|
||||||
import ProfileStats from './profile_stats';
|
import ProfileStats from './profile_stats';
|
||||||
|
@ -52,7 +54,20 @@ const ProfileInfoPanel: React.FC<IProfileInfoPanel> = ({ account, username }) =>
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getCustomBadges = (): React.ReactNode[] => {
|
||||||
|
const badges = getAccountBadges(account);
|
||||||
|
|
||||||
|
return badges.map(badge => (
|
||||||
|
<Badge
|
||||||
|
key={badge}
|
||||||
|
slug={badge}
|
||||||
|
title={capitalize(badgeToTag(badge))}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
const getBadges = (): React.ReactNode[] => {
|
const getBadges = (): React.ReactNode[] => {
|
||||||
|
const custom = getCustomBadges();
|
||||||
const staffBadge = getStaffBadge();
|
const staffBadge = getStaffBadge();
|
||||||
const isPatron = account.getIn(['patron', 'is_patron']) === true;
|
const isPatron = account.getIn(['patron', 'is_patron']) === true;
|
||||||
|
|
||||||
|
@ -66,11 +81,7 @@ const ProfileInfoPanel: React.FC<IProfileInfoPanel> = ({ account, username }) =>
|
||||||
badges.push(<Badge slug='patron' title='Patron' key='patron' />);
|
badges.push(<Badge slug='patron' title='Patron' key='patron' />);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (account.donor) {
|
return [...badges, ...custom];
|
||||||
badges.push(<Badge slug='donor' title='Donor' key='donor' />);
|
|
||||||
}
|
|
||||||
|
|
||||||
return badges;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderBirthday = (): React.ReactNode => {
|
const renderBirthday = (): React.ReactNode => {
|
||||||
|
|
|
@ -106,6 +106,10 @@ export function ReportModal() {
|
||||||
return import(/* webpackChunkName: "modals/report-modal/report-modal" */'../components/modals/report-modal/report-modal');
|
return import(/* webpackChunkName: "modals/report-modal/report-modal" */'../components/modals/report-modal/report-modal');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function AccountModerationModal() {
|
||||||
|
return import(/* webpackChunkName: "modals/account-moderation-modal" */'../components/modals/account-moderation-modal/account-moderation-modal');
|
||||||
|
}
|
||||||
|
|
||||||
export function MediaGallery() {
|
export function MediaGallery() {
|
||||||
return import(/* webpackChunkName: "status/media_gallery" */'../../../components/media_gallery');
|
return import(/* webpackChunkName: "status/media_gallery" */'../../../components/media_gallery');
|
||||||
}
|
}
|
||||||
|
|
|
@ -974,7 +974,7 @@
|
||||||
"soapbox_config.single_user_mode_profile_label": "Main user handle",
|
"soapbox_config.single_user_mode_profile_label": "Main user handle",
|
||||||
"soapbox_config.verified_can_edit_name_label": "Allow verified users to edit their own display name.",
|
"soapbox_config.verified_can_edit_name_label": "Allow verified users to edit their own display name.",
|
||||||
"status.actions.more": "More",
|
"status.actions.more": "More",
|
||||||
"status.admin_account": "Open moderation interface for @{name}",
|
"status.admin_account": "Moderate @{name}",
|
||||||
"status.admin_status": "Open this post in the moderation interface",
|
"status.admin_status": "Open this post in the moderation interface",
|
||||||
"status.block": "Block @{name}",
|
"status.block": "Block @{name}",
|
||||||
"status.bookmark": "Bookmark",
|
"status.bookmark": "Bookmark",
|
||||||
|
|
|
@ -56,7 +56,6 @@ export const AccountRecord = ImmutableRecord({
|
||||||
admin: false,
|
admin: false,
|
||||||
display_name_html: '',
|
display_name_html: '',
|
||||||
domain: '',
|
domain: '',
|
||||||
donor: false,
|
|
||||||
moderator: false,
|
moderator: false,
|
||||||
note_emojified: '',
|
note_emojified: '',
|
||||||
note_plain: '',
|
note_plain: '',
|
||||||
|
@ -155,9 +154,11 @@ const normalizeVerified = (account: ImmutableMap<string, any>) => {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Get donor status from tags. */
|
/** Upgrade legacy donor tag to a badge. */
|
||||||
const normalizeDonor = (account: ImmutableMap<string, any>) => {
|
const normalizeDonor = (account: ImmutableMap<string, any>) => {
|
||||||
return account.set('donor', getTags(account).includes('donor'));
|
const tags = getTags(account);
|
||||||
|
const updated = tags.includes('donor') ? tags.push('badge:donor') : tags;
|
||||||
|
return account.setIn(['pleroma', 'tags'], updated);
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Normalize Fedibird/Truth Social/Pleroma location */
|
/** Normalize Fedibird/Truth Social/Pleroma location */
|
||||||
|
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { normalizeAccount } from 'soapbox/normalizers';
|
||||||
|
|
||||||
|
import {
|
||||||
|
tagToBadge,
|
||||||
|
badgeToTag,
|
||||||
|
filterBadges,
|
||||||
|
getTagDiff,
|
||||||
|
getBadges,
|
||||||
|
} from '../badges';
|
||||||
|
|
||||||
|
import type { Account } from 'soapbox/types/entities';
|
||||||
|
|
||||||
|
test('tagToBadge', () => {
|
||||||
|
expect(tagToBadge('yolo')).toEqual('badge:yolo');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('badgeToTag', () => {
|
||||||
|
expect(badgeToTag('badge:yolo')).toEqual('yolo');
|
||||||
|
expect(badgeToTag('badge:badge:')).toEqual('badge:');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('filterBadges', () => {
|
||||||
|
const tags = ['one', 'badge:two', 'badge:three', 'four'];
|
||||||
|
const badges = ['badge:two', 'badge:three'];
|
||||||
|
expect(filterBadges(tags)).toEqual(badges);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getTagDiff', () => {
|
||||||
|
const oldTags = ['one', 'two', 'three'];
|
||||||
|
const newTags = ['three', 'four', 'five'];
|
||||||
|
|
||||||
|
const expected = {
|
||||||
|
added: ['four', 'five'],
|
||||||
|
removed: ['one', 'two'],
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(getTagDiff(oldTags, newTags)).toEqual(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getBadges', () => {
|
||||||
|
const account = normalizeAccount({ id: '1', pleroma: { tags: ['a', 'b', 'badge:c'] } }) as Account;
|
||||||
|
expect(getBadges(account)).toEqual(['badge:c']);
|
||||||
|
});
|
|
@ -0,0 +1,47 @@
|
||||||
|
import { OrderedSet as ImmutableOrderedSet } from 'immutable';
|
||||||
|
|
||||||
|
import type { Account } from 'soapbox/types/entities';
|
||||||
|
|
||||||
|
/** Convert a plain tag into a badge. */
|
||||||
|
const tagToBadge = (tag: string) => `badge:${tag}`;
|
||||||
|
|
||||||
|
/** Convert a badge into a plain tag. */
|
||||||
|
const badgeToTag = (badge: string) => badge.replace(/^badge:/, '');
|
||||||
|
|
||||||
|
/** Difference between an old and new set of tags. */
|
||||||
|
interface TagDiff {
|
||||||
|
/** New tags that were added. */
|
||||||
|
added: string[],
|
||||||
|
/** Old tags that were removed. */
|
||||||
|
removed: string[],
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the differences between two sets of tags. */
|
||||||
|
const getTagDiff = (oldTags: string[], newTags: string[]): TagDiff => {
|
||||||
|
const o = ImmutableOrderedSet(oldTags);
|
||||||
|
const n = ImmutableOrderedSet(newTags);
|
||||||
|
|
||||||
|
return {
|
||||||
|
added: n.subtract(o).toArray(),
|
||||||
|
removed: o.subtract(n).toArray(),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Returns only tags which are badges. */
|
||||||
|
const filterBadges = (tags: string[]): string[] => {
|
||||||
|
return tags.filter(tag => tag.startsWith('badge:'));
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Get badges from an account. */
|
||||||
|
const getBadges = (account: Account) => {
|
||||||
|
const tags = Array.from(account?.getIn(['pleroma', 'tags']) as Iterable<string> || []);
|
||||||
|
return filterBadges(tags);
|
||||||
|
};
|
||||||
|
|
||||||
|
export {
|
||||||
|
tagToBadge,
|
||||||
|
badgeToTag,
|
||||||
|
filterBadges,
|
||||||
|
getTagDiff,
|
||||||
|
getBadges,
|
||||||
|
};
|
|
@ -142,6 +142,12 @@ const getInstanceFeatures = (instance: Instance) => {
|
||||||
*/
|
*/
|
||||||
accountWebsite: v.software === TRUTHSOCIAL,
|
accountWebsite: v.software === TRUTHSOCIAL,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An additional moderator interface is available on the domain.
|
||||||
|
* @see /pleroma/admin
|
||||||
|
*/
|
||||||
|
adminFE: v.software === PLEROMA,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Can display announcements set by admins.
|
* Can display announcements set by admins.
|
||||||
* @see GET /api/v1/announcements
|
* @see GET /api/v1/announcements
|
||||||
|
|
Loading…
Reference in New Issue