From 89bdc9b4a1ef63d1e968732800928d34ddf8926f Mon Sep 17 00:00:00 2001 From: Chewbacca Date: Fri, 17 Mar 2023 14:20:03 -0400 Subject: [PATCH] Move Promote/Demote admin into entity store --- .../entity-store/hooks/useEntityActions.ts | 10 +- .../components/group-member-list-item.tsx | 118 +++++++++++------- .../hooks/api/groups/useBlockGroupMember.ts | 2 +- .../hooks/api/groups/useDemoteGroupMember.ts | 19 +++ .../hooks/api/groups/usePromoteGroupMember.ts | 19 +++ app/soapbox/hooks/api/index.ts | 6 + app/soapbox/hooks/useGroupRoles.ts | 9 ++ app/soapbox/locales/en.json | 6 +- app/soapbox/utils/features.ts | 7 +- 9 files changed, 143 insertions(+), 53 deletions(-) create mode 100644 app/soapbox/hooks/api/groups/useDemoteGroupMember.ts create mode 100644 app/soapbox/hooks/api/groups/usePromoteGroupMember.ts create mode 100644 app/soapbox/hooks/api/index.ts diff --git a/app/soapbox/entity-store/hooks/useEntityActions.ts b/app/soapbox/entity-store/hooks/useEntityActions.ts index 2b307afdee..eede5bcb3d 100644 --- a/app/soapbox/entity-store/hooks/useEntityActions.ts +++ b/app/soapbox/entity-store/hooks/useEntityActions.ts @@ -28,6 +28,10 @@ interface EntityActionEndpoints { delete?: string } +interface EntityCallbacks { + onSuccess?(entity: TEntity): void +} + function useEntityActions( path: EntityPath, endpoints: EntityActionEndpoints, @@ -38,7 +42,7 @@ function useEntityActions( const getState = useGetState(); const [entityType, listKey] = path; - function createEntity(params: P): Promise> { + function createEntity(params: P, callbacks: EntityCallbacks = {}): Promise> { if (!endpoints.post) return Promise.reject(endpoints); return api.post(endpoints.post, params).then((response) => { @@ -48,6 +52,10 @@ function useEntityActions( // TODO: optimistic updating dispatch(importEntities([entity], entityType, listKey)); + if (callbacks.onSuccess) { + callbacks.onSuccess(entity); + } + return { response, entity, diff --git a/app/soapbox/features/group/components/group-member-list-item.tsx b/app/soapbox/features/group/components/group-member-list-item.tsx index af701c7f85..afef35d479 100644 --- a/app/soapbox/features/group/components/group-member-list-item.tsx +++ b/app/soapbox/features/group/components/group-member-list-item.tsx @@ -2,7 +2,7 @@ import clsx from 'clsx'; import React, { useMemo } from 'react'; import { defineMessages, useIntl } from 'react-intl'; -import { groupDemoteAccount, groupKick, groupPromoteAccount } from 'soapbox/actions/groups'; +import { groupKick } from 'soapbox/actions/groups'; import { openModal } from 'soapbox/actions/modals'; import Account from 'soapbox/components/account'; import DropdownMenu from 'soapbox/components/dropdown-menu/dropdown-menu'; @@ -10,8 +10,8 @@ import { HStack } from 'soapbox/components/ui'; import { deleteEntities } from 'soapbox/entity-store/actions'; import { Entities } from 'soapbox/entity-store/entities'; import { useAccount, useAppDispatch, useFeatures } from 'soapbox/hooks'; -import { useBlockGroupMember } from 'soapbox/hooks/api/groups/useBlockGroupMember'; -import { BaseGroupRoles, useGroupRoles } from 'soapbox/hooks/useGroupRoles'; +import { useBlockGroupMember, useDemoteGroupMember, usePromoteGroupMember } from 'soapbox/hooks/api'; +import { BaseGroupRoles, TruthSocialGroupRoles, useGroupRoles } from 'soapbox/hooks/useGroupRoles'; import toast from 'soapbox/toast'; import type { Menu as IMenu } from 'soapbox/components/dropdown-menu'; @@ -24,17 +24,17 @@ const messages = defineMessages({ blocked: { id: 'group.group_mod_block.success', defaultMessage: 'You have successfully blocked @{name} from the group' }, demotedToUser: { id: 'group.group_mod_demote.success', defaultMessage: 'Demoted @{name} to group user' }, groupModBlock: { id: 'group.group_mod_block', defaultMessage: 'Ban from group' }, - groupModDemote: { id: 'group.group_mod_demote', defaultMessage: 'Demote @{name}' }, + groupModDemote: { id: 'group.group_mod_demote', defaultMessage: 'Remove {role} role' }, groupModKick: { id: 'group.group_mod_kick', defaultMessage: 'Kick @{name} from group' }, groupModPromoteAdmin: { id: 'group.group_mod_promote_admin', defaultMessage: 'Promote @{name} to group administrator' }, - groupModPromoteMod: { id: 'group.group_mod_promote_mod', defaultMessage: 'Promote @{name} to group moderator' }, + groupModPromoteMod: { id: 'group.group_mod_promote_mod', defaultMessage: 'Assign {role} role' }, kickConfirm: { id: 'confirmations.kick_from_group.confirm', defaultMessage: 'Kick' }, kickFromGroupMessage: { id: 'confirmations.kick_from_group.message', defaultMessage: 'Are you sure you want to kick @{name} from this group?' }, kicked: { id: 'group.group_mod_kick.success', defaultMessage: 'Kicked @{name} from group' }, promoteConfirm: { id: 'confirmations.promote_in_group.confirm', defaultMessage: 'Promote' }, promoteConfirmMessage: { id: 'confirmations.promote_in_group.message', defaultMessage: 'Are you sure you want to promote @{name}? You will not be able to demote them.' }, promotedToAdmin: { id: 'group.group_mod_promote_admin.success', defaultMessage: 'Promoted @{name} to group administrator' }, - promotedToMod: { id: 'group.group_mod_promote_mod.success', defaultMessage: 'Promoted @{name} to group moderator' }, + promotedToMod: { id: 'group.group_mod_promote_mod.success', defaultMessage: 'You have successfully promoted @{name} to group {role}.' }, }); interface IGroupMemberListItem { @@ -49,8 +49,10 @@ const GroupMemberListItem = (props: IGroupMemberListItem) => { const features = useFeatures(); const intl = useIntl(); - const { normalizeRole } = useGroupRoles(); + const { roles, isAdminRole, normalizeRole } = useGroupRoles(); const blockGroupMember = useBlockGroupMember(group, member); + const promoteGroupMember = usePromoteGroupMember(group, member); + const demoteGroupMember = useDemoteGroupMember(group, member); const account = useAccount(member.account.id) as AccountEntity; @@ -78,39 +80,59 @@ const GroupMemberListItem = (props: IGroupMemberListItem) => { heading: intl.formatMessage(messages.blockFromGroupHeading), message: intl.formatMessage(messages.blockFromGroupMessage, { name: account.username }), confirm: intl.formatMessage(messages.blockConfirm), - onConfirm: () => blockGroupMember({ account_ids: [member.account.id] }).then(() => { - dispatch(deleteEntities([member.id], Entities.GROUP_MEMBERSHIPS)); - toast.success(intl.formatMessage(messages.blocked, { name: account.acct })); - }), + onConfirm: () => { + blockGroupMember({ account_ids: [member.account.id] }, { + onSuccess() { + dispatch(deleteEntities([member.id], Entities.GROUP_MEMBERSHIPS)); + toast.success(intl.formatMessage(messages.blocked, { name: account.acct })); + }, + }); + }, })); }; - const onPromote = (role: 'admin' | 'moderator', warning?: boolean) => { + const onPromote = (role: TruthSocialGroupRoles | BaseGroupRoles, warning?: boolean) => { if (warning) { return dispatch(openModal('CONFIRM', { message: intl.formatMessage(messages.promoteConfirmMessage, { name: account.username }), confirm: intl.formatMessage(messages.promoteConfirm), - onConfirm: () => dispatch(groupPromoteAccount(group.id, account.id, role)).then(() => - toast.success(intl.formatMessage(role === 'admin' ? messages.promotedToAdmin : messages.promotedToMod, { name: account.acct })), - ), + onConfirm: () => { + promoteGroupMember({ role: role, account_ids: [account.id] }, { + onSuccess() { + toast.success( + intl.formatMessage( + isAdminRole(role) ? messages.promotedToAdmin : messages.promotedToMod, { name: account.acct, role }, + ), + ); + }, + }); + }, })); } else { - return dispatch(groupPromoteAccount(group.id, account.id, role)).then(() => - toast.success(intl.formatMessage(role === 'admin' ? messages.promotedToAdmin : messages.promotedToMod, { name: account.acct })), - ); + promoteGroupMember({ role: role, account_ids: [account.id] }, { + onSuccess() { + toast.success( + intl.formatMessage( + isAdminRole(role) ? messages.promotedToAdmin : messages.promotedToMod, { name: account.acct, role }, + ), + ); + }, + }); } }; - const handlePromoteToGroupAdmin = () => onPromote('admin', true); + const handlePromoteToGroupAdmin = () => onPromote(roles.admin, true); - const handlePromoteToGroupMod = () => { - onPromote('moderator', group.relationship!.role === 'moderator'); + const handleAssignModerator = () => { + onPromote(roles.moderator, false); }; const handleDemote = () => { - dispatch(groupDemoteAccount(group.id, account.id, 'user')).then(() => - toast.success(intl.formatMessage(messages.demotedToUser, { name: account.acct })), - ).catch(() => {}); + demoteGroupMember({ role: roles.user, account_ids: [account.id] }, { + onSuccess() { + toast.success(intl.formatMessage(messages.demotedToUser, { name: account.acct })); + }, + }); }; const menu: IMenu = useMemo(() => { @@ -120,6 +142,31 @@ const GroupMemberListItem = (props: IGroupMemberListItem) => { return items; } + if (isCurrentUserAdmin && !isMemberAdmin && account.acct === account.username) { + if (isMemberModerator) { + if (features.groupsPromoteToAdmin) { + items.push({ + text: intl.formatMessage(messages.groupModPromoteAdmin, { name: account.username }), + icon: require('@tabler/icons/arrow-up-circle.svg'), + action: handlePromoteToGroupAdmin, + }); + } + + items.push({ + text: intl.formatMessage(messages.groupModDemote, { role: roles.moderator, name: account.username }), + icon: require('@tabler/icons/briefcase.svg'), + action: handleDemote, + destructive: true, + }); + } else if (isMemberUser) { + items.push({ + text: intl.formatMessage(messages.groupModPromoteMod, { role: roles.moderator }), + icon: require('@tabler/icons/briefcase.svg'), + action: handleAssignModerator, + }); + } + } + if ( (isCurrentUserAdmin || isCurrentUserModerator) && (isMemberModerator || isMemberUser) && @@ -141,29 +188,6 @@ const GroupMemberListItem = (props: IGroupMemberListItem) => { }); } - if (isCurrentUserAdmin && !isMemberAdmin && account.acct === account.username) { - items.push(null); - - if (isMemberModerator) { - items.push({ - text: intl.formatMessage(messages.groupModPromoteAdmin, { name: account.username }), - icon: require('@tabler/icons/arrow-up-circle.svg'), - action: handlePromoteToGroupAdmin, - }); - items.push({ - text: intl.formatMessage(messages.groupModDemote, { name: account.username }), - icon: require('@tabler/icons/arrow-down-circle.svg'), - action: handleDemote, - }); - } else if (isMemberUser) { - items.push({ - text: intl.formatMessage(messages.groupModPromoteMod, { name: account.username }), - icon: require('@tabler/icons/arrow-up-circle.svg'), - action: handlePromoteToGroupMod, - }); - } - } - return items; }, [group, account]); diff --git a/app/soapbox/hooks/api/groups/useBlockGroupMember.ts b/app/soapbox/hooks/api/groups/useBlockGroupMember.ts index 12af739c99..36f722f272 100644 --- a/app/soapbox/hooks/api/groups/useBlockGroupMember.ts +++ b/app/soapbox/hooks/api/groups/useBlockGroupMember.ts @@ -4,7 +4,7 @@ import { useEntityActions } from 'soapbox/entity-store/hooks'; import type { Group, GroupMember } from 'soapbox/schemas'; function useBlockGroupMember(group: Group, groupMember: GroupMember) { - const { createEntity } = useEntityActions( + const { createEntity } = useEntityActions( [Entities.GROUP_MEMBERSHIPS, groupMember.id], { post: `/api/v1/groups/${group.id}/blocks` }, ); diff --git a/app/soapbox/hooks/api/groups/useDemoteGroupMember.ts b/app/soapbox/hooks/api/groups/useDemoteGroupMember.ts new file mode 100644 index 0000000000..db38164b78 --- /dev/null +++ b/app/soapbox/hooks/api/groups/useDemoteGroupMember.ts @@ -0,0 +1,19 @@ +import { z } from 'zod'; + +import { Entities } from 'soapbox/entity-store/entities'; +import { useEntityActions } from 'soapbox/entity-store/hooks'; +import { groupMemberSchema } from 'soapbox/schemas'; + +import type { Group, GroupMember } from 'soapbox/schemas'; + +function useDemoteGroupMember(group: Group, groupMember: GroupMember) { + const { createEntity } = useEntityActions( + [Entities.GROUP_MEMBERSHIPS, groupMember.id], + { post: `/api/v1/groups/${group.id}/demote` }, + { schema: z.array(groupMemberSchema).transform((arr) => arr[0]) }, + ); + + return createEntity; +} + +export { useDemoteGroupMember }; \ No newline at end of file diff --git a/app/soapbox/hooks/api/groups/usePromoteGroupMember.ts b/app/soapbox/hooks/api/groups/usePromoteGroupMember.ts new file mode 100644 index 0000000000..148980f0ca --- /dev/null +++ b/app/soapbox/hooks/api/groups/usePromoteGroupMember.ts @@ -0,0 +1,19 @@ +import { z } from 'zod'; + +import { Entities } from 'soapbox/entity-store/entities'; +import { useEntityActions } from 'soapbox/entity-store/hooks'; +import { groupMemberSchema } from 'soapbox/schemas'; + +import type { Group, GroupMember } from 'soapbox/schemas'; + +function usePromoteGroupMember(group: Group, groupMember: GroupMember) { + const { createEntity } = useEntityActions( + [Entities.GROUP_MEMBERSHIPS, groupMember.id], + { post: `/api/v1/groups/${group.id}/promote` }, + { schema: z.array(groupMemberSchema).transform((arr) => arr[0]) }, + ); + + return createEntity; +} + +export { usePromoteGroupMember }; \ No newline at end of file diff --git a/app/soapbox/hooks/api/index.ts b/app/soapbox/hooks/api/index.ts new file mode 100644 index 0000000000..144196a2ce --- /dev/null +++ b/app/soapbox/hooks/api/index.ts @@ -0,0 +1,6 @@ +/** + * Groups + */ +export { useBlockGroupMember } from './groups/useBlockGroupMember'; +export { useDemoteGroupMember } from './groups/useDemoteGroupMember'; +export { usePromoteGroupMember } from './groups/usePromoteGroupMember'; \ No newline at end of file diff --git a/app/soapbox/hooks/useGroupRoles.ts b/app/soapbox/hooks/useGroupRoles.ts index dd435ce169..138c8685ee 100644 --- a/app/soapbox/hooks/useGroupRoles.ts +++ b/app/soapbox/hooks/useGroupRoles.ts @@ -30,6 +30,14 @@ const useGroupRoles = () => { const isTruthSocial = version.software === TRUTHSOCIAL; const selectedRoles = isTruthSocial ? TruthSocialGroupRoles : BaseGroupRoles; + const isAdminRole = (role: TruthSocialGroupRoles | BaseGroupRoles) => { + if (isTruthSocial) { + return role === TruthSocialGroupRoles.ADMIN; + } + + return role === BaseGroupRoles.ADMIN; + }; + const normalizeRole = (role: TruthSocialGroupRoles) => { if (isTruthSocial) { return roleMap[role]; @@ -39,6 +47,7 @@ const useGroupRoles = () => { }; return { + isAdminRole, normalizeRole, roles: { admin: selectedRoles.ADMIN, diff --git a/app/soapbox/locales/en.json b/app/soapbox/locales/en.json index f9ce366aed..ef79fb48a1 100644 --- a/app/soapbox/locales/en.json +++ b/app/soapbox/locales/en.json @@ -772,14 +772,14 @@ "group.group_mod_authorize.success": "Accepted @{name} to group", "group.group_mod_block": "Ban from group", "group.group_mod_block.success": "You have successfully blocked @{name} from the group", - "group.group_mod_demote": "Demote @{name}", + "group.group_mod_demote": "Remove {role} role", "group.group_mod_demote.success": "Demoted @{name} to group user", "group.group_mod_kick": "Kick @{name} from group", "group.group_mod_kick.success": "Kicked @{name} from group", "group.group_mod_promote_admin": "Promote @{name} to group administrator", "group.group_mod_promote_admin.success": "Promoted @{name} to group administrator", - "group.group_mod_promote_mod": "Promote @{name} to group moderator", - "group.group_mod_promote_mod.success": "Promoted @{name} to group moderator", + "group.group_mod_promote_mod": "Assign {role} role", + "group.group_mod_promote_mod.success": "You have successfully promoted @{name} to group {role}.", "group.group_mod_reject": "Reject", "group.group_mod_reject.success": "Rejected @{name} from group", "group.group_mod_unblock": "Unblock", diff --git a/app/soapbox/utils/features.ts b/app/soapbox/utils/features.ts index e99d4c9219..1a1ae28bcd 100644 --- a/app/soapbox/utils/features.ts +++ b/app/soapbox/utils/features.ts @@ -528,9 +528,14 @@ const getInstanceFeatures = (instance: Instance) => { /** * Can query pending Group requests. - */ + */ groupsPending: v.software === TRUTHSOCIAL, + /** + * Can promote members to Admins. + */ + groupsPromoteToAdmin: v.software !== TRUTHSOCIAL, + /** * Can hide follows/followers lists and counts. * @see PATCH /api/v1/accounts/update_credentials