diff --git a/app/soapbox/actions/admin.ts b/app/soapbox/actions/admin.ts index 660b52dce..02af5e87a 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'; @@ -403,6 +404,12 @@ const tagUsers = (accountIds: string[], tags: string[]) => const untagUsers = (accountIds: string[], tags: string[]) => (dispatch: AppDispatch, getState: () => RootState) => { 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 }); return api(getState) .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) => (dispatch: AppDispatch) => dispatch(tagUsers([accountId], ['verified'])); @@ -421,14 +446,6 @@ const unverifyUser = (accountId: string) => (dispatch: AppDispatch) => 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) => (dispatch: AppDispatch, getState: () => RootState) => { const nicknames = nicknamesFromIds(getState, accountIds); @@ -476,6 +493,18 @@ const demoteToUser = (accountId: string) => 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[]) => (dispatch: AppDispatch, getState: () => RootState) => { const nicknames = nicknamesFromIds(getState, accountIds); @@ -567,15 +596,16 @@ export { fetchModerationLog, tagUsers, untagUsers, + setTags, + setBadges, verifyUser, unverifyUser, - setDonor, - removeDonor, addPermission, removePermission, promoteToAdmin, promoteToModerator, demoteToUser, + setRole, suggestUsers, unsuggestUsers, }; diff --git a/app/soapbox/components/badge.tsx b/app/soapbox/components/badge.tsx index 13646bcdb..01b792bd0 100644 --- a/app/soapbox/components/badge.tsx +++ b/app/soapbox/components/badge.tsx @@ -3,24 +3,27 @@ import React from 'react'; interface IBadge { title: React.ReactNode, - slug: 'patron' | 'donor' | 'admin' | 'moderator' | 'bot' | 'opaque', + slug: string, } - /** Badge to display on a user's profile. */ -const Badge: React.FC = ({ title, slug }) => ( - - {title} - -); +const Badge: React.FC = ({ title, slug }) => { + const fallback = !['patron', 'admin', 'moderator', 'opaque', 'badge:donor'].includes(slug); + + return ( + + {title} + + ); +}; export default Badge; diff --git a/app/soapbox/components/list.tsx b/app/soapbox/components/list.tsx index ae29a5cbd..b16a36784 100644 --- a/app/soapbox/components/list.tsx +++ b/app/soapbox/components/list.tsx @@ -34,7 +34,7 @@ const ListItem: React.FC = ({ label, hint, children, onClick }) => { id: domId, className: classNames({ 'w-auto': isSelect, - }), + }, child.props.className), }); } diff --git a/app/soapbox/components/profile-hover-card.tsx b/app/soapbox/components/profile-hover-card.tsx index c96839c77..3775a107c 100644 --- a/app/soapbox/components/profile-hover-card.tsx +++ b/app/soapbox/components/profile-hover-card.tsx @@ -37,10 +37,6 @@ const getBadges = (account: Account): JSX.Element[] => { badges.push(); } - if (account.donor) { - badges.push(); - } - return badges; }; diff --git a/app/soapbox/components/status-action-bar.tsx b/app/soapbox/components/status-action-bar.tsx index 5bce513a5..2dbe0b181 100644 --- a/app/soapbox/components/status-action-bar.tsx +++ b/app/soapbox/components/status-action-bar.tsx @@ -10,7 +10,7 @@ import { launchChat } from 'soapbox/actions/chats'; import { directCompose, mentionCompose, quoteCompose, replyCompose } from 'soapbox/actions/compose'; import { toggleBookmark, toggleFavourite, togglePin, toggleReblog } from 'soapbox/actions/interactions'; 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 { initReport } from 'soapbox/actions/reports'; import { deleteStatus, editStatus, toggleMuteStatus } from 'soapbox/actions/statuses'; @@ -51,7 +51,7 @@ const messages = defineMessages({ pin: { id: 'status.pin', defaultMessage: 'Pin on profile' }, unpin: { id: 'status.unpin', defaultMessage: 'Unpin from profile' }, 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' }, copy: { id: 'status.copy', defaultMessage: 'Copy link to post' }, group_remove_account: { id: 'status.remove_account_from_group', defaultMessage: 'Remove account from group' }, @@ -321,14 +321,10 @@ const StatusActionBar: React.FC = ({ } }; - const handleDeactivateUser: React.EventHandler = (e) => { + const onModerate: React.MouseEventHandler = (e) => { e.stopPropagation(); - dispatch(deactivateUserModal(intl, status.getIn(['account', 'id']) as string)); - }; - - const handleDeleteUser: React.EventHandler = (e) => { - e.stopPropagation(); - dispatch(deleteUserModal(intl, status.getIn(['account', 'id']) as string)); + const account = status.account as Account; + dispatch(openModal('ACCOUNT_MODERATION', { accountId: account.id })); }; const handleDeleteStatus: React.EventHandler = (e) => { @@ -474,13 +470,13 @@ const StatusActionBar: React.FC = ({ if (isStaff) { menu.push(null); + menu.push({ + text: intl.formatMessage(messages.adminAccount, { name: username }), + action: onModerate, + icon: require('@tabler/icons/gavel.svg'), + }); + if (isAdmin) { - menu.push({ - text: intl.formatMessage(messages.admin_account, { name: username }), - href: `/pleroma/admin/#/users/${status.getIn(['account', 'id'])}/`, - icon: require('@tabler/icons/gavel.svg'), - action: (event) => event.stopPropagation(), - }); menu.push({ text: intl.formatMessage(messages.admin_status), href: `/pleroma/admin/#/statuses/${status.id}/`, @@ -496,17 +492,6 @@ const StatusActionBar: React.FC = ({ }); 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({ text: intl.formatMessage(messages.deleteStatus), action: handleDeleteStatus, 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 new file mode 100644 index 000000000..e4f8548ab --- /dev/null +++ b/app/soapbox/components/ui/tag-input/tag-input.tsx @@ -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 = ({ 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 ( +
+ + {tags.map((tag, i) => ( +
+ +
+ ))} + + setInput(e.target.value)} + onKeyDown={handleKeyDown} + /> +
+
+ ); +}; + +export default TagInput; \ No newline at end of file diff --git a/app/soapbox/components/ui/tag-input/tag.tsx b/app/soapbox/components/ui/tag-input/tag.tsx new file mode 100644 index 000000000..d47b1d73b --- /dev/null +++ b/app/soapbox/components/ui/tag-input/tag.tsx @@ -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 = ({ tag, onDelete }) => { + return ( +
+ {tag} + + onDelete(tag)} + transparent + /> +
+ ); +}; + +export default Tag; \ No newline at end of file diff --git a/app/soapbox/features/account/components/header.tsx b/app/soapbox/features/account/components/header.tsx index c9620485b..925a63b16 100644 --- a/app/soapbox/features/account/components/header.tsx +++ b/app/soapbox/features/account/components/header.tsx @@ -6,12 +6,10 @@ import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import { Link, useHistory } from 'react-router-dom'; 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 { mentionCompose, directCompose } from 'soapbox/actions/compose'; import { blockDomain, unblockDomain } from 'soapbox/actions/domain_blocks'; import { openModal } from 'soapbox/actions/modals'; -import { deactivateUserModal } from 'soapbox/actions/moderation'; import { initMuteModal } from 'soapbox/actions/mutes'; import { initReport } from 'soapbox/actions/reports'; 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 { useAppDispatch, useFeatures, useOwnAccount } from 'soapbox/hooks'; import { Account } from 'soapbox/types/entities'; -import { - isLocal, - isRemote, -} from 'soapbox/utils/accounts'; +import { isRemote } from 'soapbox/utils/accounts'; 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' }, unendorse: { id: 'account.unendorse', defaultMessage: 'Don\'t feature on profile' }, 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' }, - 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}' }, searchSelf: { id: 'account.search_self', defaultMessage: 'Search your posts' }, unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' }, blockConfirm: { id: 'confirmations.block.confirm', defaultMessage: 'Block' }, blockDomainConfirm: { id: 'confirmations.domain_block.confirm', defaultMessage: 'Hide entire domain' }, 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' }, 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}' }, - }); interface IHeader { @@ -210,81 +182,8 @@ const Header: React.FC = ({ account }) => { dispatch(launchChat(account.id, history)); }; - const onDeactivateUser = () => { - dispatch(deactivateUserModal(intl, 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 onModerate = () => { + dispatch(openModal('ACCOUNT_MODERATION', { accountId: account.id })); }; const onRemoveFromFollowers = () => { @@ -532,107 +431,11 @@ const Header: React.FC = ({ account }) => { if (ownAccount?.staff) { menu.push(null); - if (ownAccount?.admin) { - menu.push({ - text: intl.formatMessage(messages.admin_account, { name: account.username }), - to: `/pleroma/admin/#/users/${account.id}/`, - newTab: true, - 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'), - }); - } + menu.push({ + text: intl.formatMessage(messages.adminAccount, { name: account.username }), + action: onModerate, + icon: require('@tabler/icons/gavel.svg'), + }); } return menu; diff --git a/app/soapbox/features/ui/components/modal_root.js b/app/soapbox/features/ui/components/modal_root.js index f7b9b007c..2c2de0530 100644 --- a/app/soapbox/features/ui/components/modal_root.js +++ b/app/soapbox/features/ui/components/modal_root.js @@ -32,6 +32,7 @@ import { CompareHistoryModal, VerifySmsModal, FamiliarFollowersModal, + AccountModerationModal, } from 'soapbox/features/ui/util/async-components'; import BundleContainer from '../containers/bundle_container'; @@ -69,6 +70,7 @@ const MODAL_COMPONENTS = { 'COMPARE_HISTORY': CompareHistoryModal, 'VERIFY_SMS': VerifySmsModal, 'FAMILIAR_FOLLOWERS': FamiliarFollowersModal, + 'ACCOUNT_MODERATION': AccountModerationModal, }; export default class ModalRoot extends React.PureComponent { 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 new file mode 100644 index 000000000..8f91e1d3c --- /dev/null +++ b/app/soapbox/features/ui/components/modals/account-moderation-modal/account-moderation-modal.tsx @@ -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 = ({ 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(accountBadges); + + const handleClose = () => onClose('ACCOUNT_MODERATION'); + + if (!account || !ownAccount) { + return ( + + + + ); + } + + const handleAdminFE = () => { + window.open(`/pleroma/admin/#/users/${account.id}/`, '_blank'); + }; + + const handleVerifiedChange: ChangeEventHandler = (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 = (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 ( + } + onClose={handleClose} + > + +
+ +
+ + + {(ownAccount.admin && isLocal(account)) && ( + }> +
+ +
+
+ )} + + }> + + + + {features.suggestionsV2 && ( + }> + + + )} + + }> +
+ + + + +
+
+
+ + + } + onClick={handleDeactivate} + /> + + } + onClick={handleDelete} + /> + + + + + + + {features.adminFE && ( + + + + )} +
+
+ ); +}; + +export default AccountModerationModal; 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/features/ui/components/modals/account-moderation-modal/staff-role-picker.tsx b/app/soapbox/features/ui/components/modals/account-moderation-modal/staff-role-picker.tsx new file mode 100644 index 000000000..30502d427 --- /dev/null +++ b/app/soapbox/features/ui/components/modals/account-moderation-modal/staff-role-picker.tsx @@ -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 = ({ account }) => { + const intl = useIntl(); + const dispatch = useAppDispatch(); + + const roles: Record = useMemo(() => ({ + user: intl.formatMessage(messages.roleUser), + moderator: intl.formatMessage(messages.roleModerator), + admin: intl.formatMessage(messages.roleAdmin), + }), []); + + const handleRoleChange: React.ChangeEventHandler = (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 ( + + ); +}; + +export default StaffRolePicker; \ No newline at end of file diff --git a/app/soapbox/features/ui/components/profile_info_panel.tsx b/app/soapbox/features/ui/components/profile_info_panel.tsx index 2c0a9163b..c6c6d084e 100644 --- a/app/soapbox/features/ui/components/profile_info_panel.tsx +++ b/app/soapbox/features/ui/components/profile_info_panel.tsx @@ -8,6 +8,8 @@ import { Icon, HStack, Stack, Text } from 'soapbox/components/ui'; import VerificationBadge from 'soapbox/components/verification_badge'; import { useSoapboxConfig } from 'soapbox/hooks'; 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 ProfileStats from './profile_stats'; @@ -52,7 +54,20 @@ const ProfileInfoPanel: React.FC = ({ account, username }) => } }; + const getCustomBadges = (): React.ReactNode[] => { + const badges = getAccountBadges(account); + + return badges.map(badge => ( + + )); + }; + const getBadges = (): React.ReactNode[] => { + const custom = getCustomBadges(); const staffBadge = getStaffBadge(); const isPatron = account.getIn(['patron', 'is_patron']) === true; @@ -66,11 +81,7 @@ const ProfileInfoPanel: React.FC = ({ account, username }) => badges.push(); } - if (account.donor) { - badges.push(); - } - - return badges; + return [...badges, ...custom]; }; const renderBirthday = (): React.ReactNode => { diff --git a/app/soapbox/features/ui/util/async-components.ts b/app/soapbox/features/ui/util/async-components.ts index 43b5c16a1..9f1826fe8 100644 --- a/app/soapbox/features/ui/util/async-components.ts +++ b/app/soapbox/features/ui/util/async-components.ts @@ -106,6 +106,10 @@ export function ReportModal() { 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() { return import(/* webpackChunkName: "status/media_gallery" */'../../../components/media_gallery'); } diff --git a/app/soapbox/locales/en.json b/app/soapbox/locales/en.json index cded97f64..7ce350976 100644 --- a/app/soapbox/locales/en.json +++ b/app/soapbox/locales/en.json @@ -974,7 +974,7 @@ "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.", "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.block": "Block @{name}", "status.bookmark": "Bookmark", diff --git a/app/soapbox/normalizers/account.ts b/app/soapbox/normalizers/account.ts index 37f42ab8f..46d2a632f 100644 --- a/app/soapbox/normalizers/account.ts +++ b/app/soapbox/normalizers/account.ts @@ -56,7 +56,6 @@ export const AccountRecord = ImmutableRecord({ admin: false, display_name_html: '', domain: '', - donor: false, moderator: false, note_emojified: '', note_plain: '', @@ -155,9 +154,11 @@ const normalizeVerified = (account: ImmutableMap) => { }); }; -/** Get donor status from tags. */ +/** Upgrade legacy donor tag to a badge. */ const normalizeDonor = (account: ImmutableMap) => { - 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 */ diff --git a/app/soapbox/utils/__tests__/badges.test.ts b/app/soapbox/utils/__tests__/badges.test.ts new file mode 100644 index 000000000..1f3349fcc --- /dev/null +++ b/app/soapbox/utils/__tests__/badges.test.ts @@ -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']); +}); \ 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 diff --git a/app/soapbox/utils/features.ts b/app/soapbox/utils/features.ts index 78908b04f..f220f5e67 100644 --- a/app/soapbox/utils/features.ts +++ b/app/soapbox/utils/features.ts @@ -142,6 +142,12 @@ const getInstanceFeatures = (instance: Instance) => { */ 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. * @see GET /api/v1/announcements