Admin: allow setting custom badges on accounts
This commit is contained in:
parent
90cbf0a60f
commit
2e811a1e88
6 changed files with 135 additions and 3 deletions
|
@ -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';
|
||||||
|
@ -413,6 +414,26 @@ const untagUsers = (accountIds: string[], tags: string[]) =>
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Synchronizes user tags to the backend. */
|
||||||
|
const setTags = (accountId: string, oldTags: string[], newTags: string[]) =>
|
||||||
|
(dispatch: AppDispatch) => {
|
||||||
|
const diff = getTagDiff(oldTags, newTags);
|
||||||
|
|
||||||
|
return Promise.all([
|
||||||
|
dispatch(tagUsers([accountId], diff.added)),
|
||||||
|
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']));
|
||||||
|
@ -579,6 +600,8 @@ export {
|
||||||
fetchModerationLog,
|
fetchModerationLog,
|
||||||
tagUsers,
|
tagUsers,
|
||||||
untagUsers,
|
untagUsers,
|
||||||
|
setTags,
|
||||||
|
setBadges,
|
||||||
verifyUser,
|
verifyUser,
|
||||||
unverifyUser,
|
unverifyUser,
|
||||||
setDonor,
|
setDonor,
|
||||||
|
|
|
@ -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';
|
||||||
|
|
|
@ -43,7 +43,7 @@ const TagInput: React.FC<ITagInput> = ({ tags, onChange, placeholder }) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='mt-1 relative shadow-sm'>
|
<div className='mt-1 relative shadow-sm flex-grow'>
|
||||||
<HStack
|
<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'
|
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}
|
space={2}
|
||||||
|
@ -56,7 +56,7 @@ const TagInput: React.FC<ITagInput> = ({ tags, onChange, placeholder }) => {
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<input
|
<input
|
||||||
className='p-1 mb-2 h-8 flex-grow bg-transparent outline-none'
|
className='p-1 mb-2 w-32 h-8 flex-grow bg-transparent outline-none'
|
||||||
value={input}
|
value={input}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
onChange={e => setInput(e.target.value)}
|
onChange={e => setInput(e.target.value)}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { ChangeEventHandler } from 'react';
|
import React, { ChangeEventHandler, useState } from 'react';
|
||||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
@ -8,6 +8,7 @@ import {
|
||||||
removeDonor,
|
removeDonor,
|
||||||
suggestUsers,
|
suggestUsers,
|
||||||
unsuggestUsers,
|
unsuggestUsers,
|
||||||
|
setBadges as saveBadges,
|
||||||
} from 'soapbox/actions/admin';
|
} from 'soapbox/actions/admin';
|
||||||
import { deactivateUserModal, deleteUserModal } from 'soapbox/actions/moderation';
|
import { deactivateUserModal, deleteUserModal } from 'soapbox/actions/moderation';
|
||||||
import snackbar from 'soapbox/actions/snackbar';
|
import snackbar from 'soapbox/actions/snackbar';
|
||||||
|
@ -18,7 +19,9 @@ import { Button, Text, HStack, Modal, Stack, Toggle } from 'soapbox/components/u
|
||||||
import { useAppDispatch, useAppSelector, useFeatures, useOwnAccount } from 'soapbox/hooks';
|
import { useAppDispatch, useAppSelector, useFeatures, useOwnAccount } from 'soapbox/hooks';
|
||||||
import { makeGetAccount } from 'soapbox/selectors';
|
import { makeGetAccount } from 'soapbox/selectors';
|
||||||
import { isLocal } from 'soapbox/utils/accounts';
|
import { isLocal } from 'soapbox/utils/accounts';
|
||||||
|
import { getBadges } from 'soapbox/utils/badges';
|
||||||
|
|
||||||
|
import BadgeInput from './badge-input';
|
||||||
import StaffRolePicker from './staff-role-picker';
|
import StaffRolePicker from './staff-role-picker';
|
||||||
|
|
||||||
const getAccount = makeGetAccount();
|
const getAccount = makeGetAccount();
|
||||||
|
@ -30,6 +33,7 @@ const messages = defineMessages({
|
||||||
removeDonorSuccess: { id: 'admin.users.remove_donor_message', defaultMessage: '@{acct} was removed 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' },
|
userSuggested: { id: 'admin.users.user_suggested_message', defaultMessage: '@{acct} was suggested' },
|
||||||
userUnsuggested: { id: 'admin.users.user_unsuggested_message', defaultMessage: '@{acct} was unsuggested' },
|
userUnsuggested: { id: 'admin.users.user_unsuggested_message', defaultMessage: '@{acct} was unsuggested' },
|
||||||
|
badgesSaved: { id: 'admin.users.badges_saved_message', defaultMessage: 'Custom badges updated.' },
|
||||||
});
|
});
|
||||||
|
|
||||||
interface IAccountModerationModal {
|
interface IAccountModerationModal {
|
||||||
|
@ -48,6 +52,9 @@ const AccountModerationModal: React.FC<IAccountModerationModal> = ({ onClose, ac
|
||||||
const features = useFeatures();
|
const features = useFeatures();
|
||||||
const account = useAppSelector(state => getAccount(state, accountId));
|
const account = useAppSelector(state => getAccount(state, accountId));
|
||||||
|
|
||||||
|
const accountBadges = account ? getBadges(account) : [];
|
||||||
|
const [badges, setBadges] = useState<string[]>(accountBadges);
|
||||||
|
|
||||||
const handleClose = () => onClose('ACCOUNT_MODERATION');
|
const handleClose = () => onClose('ACCOUNT_MODERATION');
|
||||||
|
|
||||||
if (!account || !ownAccount) {
|
if (!account || !ownAccount) {
|
||||||
|
@ -103,6 +110,12 @@ const AccountModerationModal: React.FC<IAccountModerationModal> = ({ onClose, ac
|
||||||
dispatch(deleteUserModal(intl, account.id));
|
dispatch(deleteUserModal(intl, account.id));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSaveBadges = () => {
|
||||||
|
dispatch(saveBadges(account.id, accountBadges, badges))
|
||||||
|
.then(() => dispatch(snackbar.success(intl.formatMessage(messages.badgesSaved))))
|
||||||
|
.catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
title={<FormattedMessage id='account_moderation_modal.title' defaultMessage='Moderate @{acct}' values={{ acct: account.acct }} />}
|
title={<FormattedMessage id='account_moderation_modal.title' defaultMessage='Moderate @{acct}' values={{ acct: account.acct }} />}
|
||||||
|
@ -149,6 +162,17 @@ const AccountModerationModal: React.FC<IAccountModerationModal> = ({ onClose, ac
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</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>
|
||||||
|
|
||||||
<List>
|
<List>
|
||||||
|
|
|
@ -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;
|
47
app/soapbox/utils/badges.ts
Normal file
47
app/soapbox/utils/badges.ts
Normal file
|
@ -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,
|
||||||
|
};
|
Loading…
Reference in a new issue