diff --git a/app/soapbox/actions/admin.ts b/app/soapbox/actions/admin.ts index e486ca873..ee4fae92c 100644 --- a/app/soapbox/actions/admin.ts +++ b/app/soapbox/actions/admin.ts @@ -1,5 +1,6 @@ import { fetchRelationships } from 'soapbox/actions/accounts'; import { importFetchedAccount, importFetchedAccounts, importFetchedStatuses } from 'soapbox/actions/importer'; +import { filterBadges, getTagDiff } from 'soapbox/utils/badges'; import { getFeatures } from 'soapbox/utils/features'; 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) => (dispatch: AppDispatch) => dispatch(tagUsers([accountId], ['verified'])); @@ -579,6 +600,8 @@ export { fetchModerationLog, tagUsers, untagUsers, + setTags, + setBadges, verifyUser, unverifyUser, setDonor, diff --git a/app/soapbox/components/ui/index.ts b/app/soapbox/components/ui/index.ts index 5eff0a78f..fd9cb055e 100644 --- a/app/soapbox/components/ui/index.ts +++ b/app/soapbox/components/ui/index.ts @@ -34,6 +34,7 @@ export { default as Select } from './select/select'; export { default as Spinner } from './spinner/spinner'; export { default as Stack } from './stack/stack'; 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 Textarea } from './textarea/textarea'; export { default as Toggle } from './toggle/toggle'; diff --git a/app/soapbox/components/ui/tag-input/tag-input.tsx b/app/soapbox/components/ui/tag-input/tag-input.tsx index b692e82e9..e4f8548ab 100644 --- a/app/soapbox/components/ui/tag-input/tag-input.tsx +++ b/app/soapbox/components/ui/tag-input/tag-input.tsx @@ -43,7 +43,7 @@ const TagInput: React.FC = ({ tags, onChange, placeholder }) => { }; return ( -
+
= ({ tags, onChange, placeholder }) => { ))} setInput(e.target.value)} diff --git a/app/soapbox/features/ui/components/modals/account-moderation-modal/account-moderation-modal.tsx b/app/soapbox/features/ui/components/modals/account-moderation-modal/account-moderation-modal.tsx index be3c01d2c..d9d104b84 100644 --- a/app/soapbox/features/ui/components/modals/account-moderation-modal/account-moderation-modal.tsx +++ b/app/soapbox/features/ui/components/modals/account-moderation-modal/account-moderation-modal.tsx @@ -1,4 +1,4 @@ -import React, { ChangeEventHandler } from 'react'; +import React, { ChangeEventHandler, useState } from 'react'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import { @@ -8,6 +8,7 @@ import { removeDonor, suggestUsers, unsuggestUsers, + setBadges as saveBadges, } from 'soapbox/actions/admin'; import { deactivateUserModal, deleteUserModal } from 'soapbox/actions/moderation'; 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 { 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(); @@ -30,6 +33,7 @@ const messages = defineMessages({ 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 { @@ -48,6 +52,9 @@ const AccountModerationModal: React.FC = ({ onClose, ac const features = useFeatures(); const account = useAppSelector(state => getAccount(state, accountId)); + const accountBadges = account ? getBadges(account) : []; + const [badges, setBadges] = useState(accountBadges); + const handleClose = () => onClose('ACCOUNT_MODERATION'); if (!account || !ownAccount) { @@ -103,6 +110,12 @@ const AccountModerationModal: React.FC = ({ onClose, ac dispatch(deleteUserModal(intl, account.id)); }; + const handleSaveBadges = () => { + dispatch(saveBadges(account.id, accountBadges, badges)) + .then(() => dispatch(snackbar.success(intl.formatMessage(messages.badgesSaved)))) + .catch(() => {}); + }; + return ( } @@ -149,6 +162,17 @@ const AccountModerationModal: React.FC = ({ onClose, ac /> )} + + }> +
+ + + + +
+
diff --git a/app/soapbox/features/ui/components/modals/account-moderation-modal/badge-input.tsx b/app/soapbox/features/ui/components/modals/account-moderation-modal/badge-input.tsx new file mode 100644 index 000000000..63ef22fad --- /dev/null +++ b/app/soapbox/features/ui/components/modals/account-moderation-modal/badge-input.tsx @@ -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 = ({ badges, onChange }) => { + const intl = useIntl(); + const tags = badges.map(badgeToTag); + + const handleTagsChange = (tags: string[]) => { + const badges = tags.map(tagToBadge); + onChange(badges); + }; + + return ( + + ); +}; + +export default BadgeInput; \ No newline at end of file diff --git a/app/soapbox/utils/badges.ts b/app/soapbox/utils/badges.ts new file mode 100644 index 000000000..139537f6f --- /dev/null +++ b/app/soapbox/utils/badges.ts @@ -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 || []); + return filterBadges(tags); +}; + +export { + tagToBadge, + badgeToTag, + filterBadges, + getTagDiff, + getBadges, +}; \ No newline at end of file