diff --git a/app/soapbox/actions/groups.ts b/app/soapbox/actions/groups.ts index ef29f48aa..ca023c4ec 100644 --- a/app/soapbox/actions/groups.ts +++ b/app/soapbox/actions/groups.ts @@ -406,7 +406,7 @@ const groupKick = (groupId: string, accountId: string) => (dispatch: AppDispatch, getState: () => RootState) => { dispatch(groupKickRequest(groupId, accountId)); - api(getState).post(`/api/v1/groups/${groupId}/kick`, { account_ids: [accountId] }) + return api(getState).post(`/api/v1/groups/${groupId}/kick`, { account_ids: [accountId] }) .then(() => dispatch(groupKickSuccess(groupId, accountId))) .catch(err => dispatch(groupKickFail(groupId, accountId, err))); }; @@ -506,7 +506,7 @@ const groupBlock = (groupId: string, accountId: string) => (dispatch: AppDispatch, getState: () => RootState) => { dispatch(groupBlockRequest(groupId, accountId)); - api(getState).post(`/api/v1/groups/${groupId}/blocks`, { account_ids: [accountId] }) + return api(getState).post(`/api/v1/groups/${groupId}/blocks`, { account_ids: [accountId] }) .then(() => dispatch(groupBlockSuccess(groupId, accountId))) .catch(err => dispatch(groupBlockFail(groupId, accountId, err))); }; @@ -534,7 +534,7 @@ const groupUnblock = (groupId: string, accountId: string) => (dispatch: AppDispatch, getState: () => RootState) => { dispatch(groupUnblockRequest(groupId, accountId)); - api(getState).delete(`/api/v1/groups/${groupId}/blocks?account_ids[]=${accountId}`) + return api(getState).delete(`/api/v1/groups/${groupId}/blocks?account_ids[]=${accountId}`) .then(() => dispatch(groupUnblockSuccess(groupId, accountId))) .catch(err => dispatch(groupUnblockFail(groupId, accountId, err))); }; @@ -562,7 +562,7 @@ const groupPromoteAccount = (groupId: string, accountId: string, role: GroupRole (dispatch: AppDispatch, getState: () => RootState) => { dispatch(groupPromoteAccountRequest(groupId, accountId)); - api(getState).post(`/api/v1/groups/${groupId}/promote`, { account_ids: [accountId], role: role }) + return api(getState).post(`/api/v1/groups/${groupId}/promote`, { account_ids: [accountId], role: role }) .then((response) => dispatch(groupPromoteAccountSuccess(groupId, accountId, response.data))) .catch(err => dispatch(groupPromoteAccountFail(groupId, accountId, err))); }; @@ -591,7 +591,7 @@ const groupDemoteAccount = (groupId: string, accountId: string, role: GroupRole) (dispatch: AppDispatch, getState: () => RootState) => { dispatch(groupDemoteAccountRequest(groupId, accountId)); - api(getState).post(`/api/v1/groups/${groupId}/demote`, { account_ids: [accountId], role: role }) + return api(getState).post(`/api/v1/groups/${groupId}/demote`, { account_ids: [accountId], role: role }) .then((response) => dispatch(groupDemoteAccountSuccess(groupId, accountId, response.data))) .catch(err => dispatch(groupDemoteAccountFail(groupId, accountId, err))); }; diff --git a/app/soapbox/components/group-card.tsx b/app/soapbox/components/group-card.tsx index b45ea29e7..faade0dc7 100644 --- a/app/soapbox/components/group-card.tsx +++ b/app/soapbox/components/group-card.tsx @@ -31,7 +31,7 @@ const GroupCard: React.FC = ({ group }) => { {group.relationship?.role === 'admin' ? ( - + ) : group.relationship?.role === 'moderator' && ( diff --git a/app/soapbox/features/group/components/group-header.tsx b/app/soapbox/features/group/components/group-header.tsx index 812f77e79..ef6e08324 100644 --- a/app/soapbox/features/group/components/group-header.tsx +++ b/app/soapbox/features/group/components/group-header.tsx @@ -173,7 +173,7 @@ const GroupHeader: React.FC = ({ group }) => { {group.relationship?.role === 'admin' ? ( - + ) : group.relationship?.role === 'moderator' && ( diff --git a/app/soapbox/features/group/group-members.tsx b/app/soapbox/features/group/group-members.tsx index 7e7f368a0..5eca02b7a 100644 --- a/app/soapbox/features/group/group-members.tsx +++ b/app/soapbox/features/group/group-members.tsx @@ -1,28 +1,210 @@ import debounce from 'lodash/debounce'; import React, { useCallback, useEffect } from 'react'; import { defineMessages, useIntl } from 'react-intl'; +import { Link } from 'react-router-dom'; -import { expandGroupMemberships, fetchGroup, fetchGroupMemberships } from 'soapbox/actions/groups'; +import { expandGroupMemberships, fetchGroup, fetchGroupMemberships, groupBlock, groupDemoteAccount, groupKick, groupPromoteAccount } from 'soapbox/actions/groups'; +import { openModal } from 'soapbox/actions/modals'; +import snackbar from 'soapbox/actions/snackbar'; import ScrollableList from 'soapbox/components/scrollable-list'; -import { CardHeader, CardTitle } from 'soapbox/components/ui'; +import { CardHeader, CardTitle, HStack, IconButton, Menu, MenuButton, MenuDivider, MenuItem, MenuLink, MenuList } from 'soapbox/components/ui'; +import SvgIcon from 'soapbox/components/ui/icon/svg-icon'; import AccountContainer from 'soapbox/containers/account-container'; import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; +import { makeGetAccount } from 'soapbox/selectors'; import PlaceholderAccount from '../placeholder/components/placeholder-account'; -import type { List } from 'soapbox/reducers/group-memberships'; +import type { Menu as MenuType } from 'soapbox/components/dropdown-menu'; +import type { GroupRole, List } from 'soapbox/reducers/group-memberships'; +import type { GroupRelationship } from 'soapbox/types/entities'; type RouteParams = { id: string }; -interface IGroupMembers { - params: RouteParams, +const messages = defineMessages({ + adminSubheading: { id: 'group.admin_subheading', defaultMessage: 'Group administrators' }, + moderatorSubheading: { id: 'group.moderator_subheading', defaultMessage: 'Group moderators' }, + userSubheading: { id: 'group.user_subheading', defaultMessage: 'Users' }, + groupModKick: { id: 'group.group_mod_kick', defaultMessage: 'Kick @{name} from group' }, + groupModBlock: { id: 'group.group_mod_block', defaultMessage: 'Block @{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' }, + groupModDemote: { id: 'group.group_mod_demote', defaultMessage: 'Demote @{name}' }, + kickFromGroupMessage: { id: 'confirmations.kick_from_group.message', defaultMessage: 'Are you sure you want to kick @{name} from this group?' }, + kickConfirm: { id: 'confirmations.kick_from_group.confirm', defaultMessage: 'Kick' }, + blockFromGroupMessage: { id: 'confirmations.block_from_group.message', defaultMessage: 'Are you sure you want to block @{name} from interacting with this group?' }, + blockConfirm: { id: 'confirmations.block_from_group.confirm', defaultMessage: 'Block' }, + promoteConfirmMessage: { id: 'confirmations.promote_in_group.message', defaultMessage: 'Are you sure you want to promote @{name}? You will not be able to demote them.' }, + promoteConfirm: { id: 'confirmations.promote_in_group.confirm', defaultMessage: 'Promote' }, + kicked: { id: 'group.group_mod_kick.success', defaultMessage: 'Kicked @{name} from group' }, + blocked: { id: 'group.group_mod_block.success', defaultMessage: 'Blocked @{name} from group' }, + 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' }, + demotedToUser: { id: 'group.group_mod_demote.success', defaultMessage: 'Demoted @{name} to group user' }, +}); + +interface IGroupMember { + accountId: string + accountRole: GroupRole + groupId: string + relationship?: GroupRelationship } -const messages = defineMessages({ - adminSubheading: { id: 'groups.admin_subheading', defaultMessage: 'Group administrators' }, - moderatorSubheading: { id: 'groups.moderator_subheading', defaultMessage: 'Group moderators' }, - userSubheading: { id: 'groups.user_subheading', defaultMessage: 'Users' }, -}); +const GroupMember: React.FC = ({ accountId, accountRole, groupId, relationship }) => { + const intl = useIntl(); + const dispatch = useAppDispatch(); + + const getAccount = useCallback(makeGetAccount(), []); + + const account = useAppSelector((state) => getAccount(state, accountId)); + + if (!account) return null; + + const handleKickFromGroup = () => { + dispatch(openModal('CONFIRM', { + message: intl.formatMessage(messages.kickFromGroupMessage, { name: account.username }), + confirm: intl.formatMessage(messages.kickConfirm), + onConfirm: () => dispatch(groupKick(groupId, account.id)).then(() => + dispatch(snackbar.success(intl.formatMessage(messages.kicked, { name: account.acct }))), + ), + })); + }; + + const handleBlockFromGroup = () => { + dispatch(openModal('CONFIRM', { + message: intl.formatMessage(messages.blockFromGroupMessage, { name: account.username }), + confirm: intl.formatMessage(messages.blockConfirm), + onConfirm: () => dispatch(groupBlock(groupId, account.id)).then(() => + dispatch(snackbar.success(intl.formatMessage(messages.blocked, { name: account.acct }))), + ), + })); + }; + + const onPromote = (role: 'admin' | 'moderator', warning?: boolean) => { + if (warning) { + return dispatch(openModal('CONFIRM', { + message: intl.formatMessage(messages.promoteConfirmMessage, { name: account.username }), + confirm: intl.formatMessage(messages.promoteConfirm), + onConfirm: () => dispatch(groupPromoteAccount(groupId, account.id, role)).then(() => + dispatch(snackbar.success(intl.formatMessage(role === 'admin' ? messages.promotedToAdmin : messages.promotedToMod, { name: account.acct }))), + ), + })); + } else { + return dispatch(groupPromoteAccount(groupId, account.id, role)).then(() => + dispatch(snackbar.success(intl.formatMessage(role === 'admin' ? messages.promotedToAdmin : messages.promotedToMod, { name: account.acct }))), + ); + } + }; + + const handlePromoteToGroupAdmin = () => { + onPromote('admin', true); + }; + + const handlePromoteToGroupMod = () => { + onPromote('moderator', relationship!.role === 'moderator'); + }; + + const handleDemote = () => { + dispatch(groupDemoteAccount(groupId, account.id, 'user')).then(() => + dispatch(snackbar.success(intl.formatMessage(messages.demotedToUser, { name: account.acct }))), + ).catch(() => {}); + }; + + const makeMenu = () => { + const menu: MenuType = []; + + if (!relationship || !relationship.role) return menu; + + if (['admin', 'moderator'].includes(relationship.role) && ['moderator', 'user'].includes(accountRole) && accountRole !== relationship.role) { + menu.push({ + text: intl.formatMessage(messages.groupModKick, { name: account.username }), + icon: require('@tabler/icons/user-minus.svg'), + action: handleKickFromGroup, + }); + menu.push({ + text: intl.formatMessage(messages.groupModBlock, { name: account.username }), + icon: require('@tabler/icons/ban.svg'), + action: handleBlockFromGroup, + }); + } + + if (relationship.role === 'admin' && accountRole !== 'admin' && account.acct === account.username) { + menu.push(null); + switch (accountRole) { + case 'moderator': + menu.push({ + text: intl.formatMessage(messages.groupModPromoteAdmin, { name: account.username }), + icon: require('@tabler/icons/arrow-up-circle.svg'), + action: handlePromoteToGroupAdmin, + }); + menu.push({ + text: intl.formatMessage(messages.groupModDemote, { name: account.username }), + icon: require('@tabler/icons/arrow-down-circle.svg'), + action: handleDemote, + }); + break; + case 'user': + menu.push({ + text: intl.formatMessage(messages.groupModPromoteMod, { name: account.username }), + icon: require('@tabler/icons/arrow-up-circle.svg'), + action: handlePromoteToGroupMod, + }); + break; + } + } + + return menu; + }; + + const menu = makeMenu(); + + return ( + +
+ +
+ {menu.length > 0 && ( + + + + + {menu.map((menuItem, idx) => { + if (typeof menuItem?.text === 'undefined') { + return ; + } else { + const Comp = (menuItem.action ? MenuItem : MenuLink) as any; + const itemProps = menuItem.action ? { onSelect: menuItem.action } : { to: menuItem.to, as: Link, target: menuItem.newTab ? '_blank' : '_self' }; + + return ( + + + {menuItem.icon && ( + + )} + +
{menuItem.text}
+
+
+ ); + } + })} +
+
+ )} +
+ ); +}; + +interface IGroupMembers { + params: RouteParams +} const GroupMembers: React.FC = (props) => { const intl = useIntl(); @@ -30,6 +212,7 @@ const GroupMembers: React.FC = (props) => { const groupId = props.params.id; + const relationship = useAppSelector((state) => state.group_relationships.get(groupId)); const admins = useAppSelector((state) => state.group_memberships.admin.get(groupId)); const moderators = useAppSelector((state) => state.group_memberships.moderator.get(groupId)); const users = useAppSelector((state) => state.group_memberships.user.get(groupId)); @@ -50,7 +233,7 @@ const GroupMembers: React.FC = (props) => { handleLoadMore('user'); }, 300, { leading: true }), []); - const renderMemberships = (memberships: List | undefined, role: 'admin' | 'moderator' | 'user', handler: () => void) => { + const renderMemberships = (memberships: List | undefined, role: GroupRole, handler: () => void) => { if (!memberships?.isLoading && !memberships?.items.count()) return; return ( @@ -68,7 +251,15 @@ const GroupMembers: React.FC = (props) => { placeholderCount={3} itemClassName='pb-4 last:pb-0' > - {memberships?.items?.map(accountId => )} + {memberships?.items?.map(accountId => ( + + ))} ); diff --git a/app/soapbox/locales/pl.json b/app/soapbox/locales/pl.json index 550d6f8df..cc1e84d14 100644 --- a/app/soapbox/locales/pl.json +++ b/app/soapbox/locales/pl.json @@ -701,17 +701,17 @@ "gdpr.message": "{siteTitle} korzysta z ciasteczek sesji, które są niezbędne dla działania strony.", "gdpr.title": "{siteTitle} korzysta z ciasteczek", "getting_started.open_source_notice": "{code_name} jest oprogramowaniem o otwartym źródle. Możesz pomóc w rozwoju lub zgłaszać błędy na GitLabie tutaj: {code_link} (v{code_version}).", - "groups.admin_subheading": "Administratorzy grupy", + "group.admin_subheading": "Administratorzy grupy", "groups.empty.title": "Brak grup", "groups.empty.subtitle": "Odkrywaj grupy do których możesz dołączyć lub utwórz własną.", - "groups.moderator_subheading": "Moderatorzy grupy", - "groups.user_subheading": "Członkowie grupy", + "group.moderator_subheading": "Moderatorzy grupy", + "group.user_subheading": "Członkowie grupy", "group.header.alt": "Nagłówek grupy", "group.join": "Dołącz do grupy", "group.leave": "Opuść grupę", "group.manage": "Edytuj grupę", "group.request_join": "Poproś o dołączenie do grupy", - "group.role.owner": "Właściciel", + "group.role.admin": "Administrator", "group.role.moderator": "Moderator", "group.privacy.locked": "Prywatna", "group.privacy.public": "Publiczna",