diff --git a/app/soapbox/actions/mutes.ts b/app/soapbox/actions/mutes.ts index a2379d44a..3589153e0 100644 --- a/app/soapbox/actions/mutes.ts +++ b/app/soapbox/actions/mutes.ts @@ -1,95 +1,12 @@ -import { isLoggedIn } from 'soapbox/utils/auth'; -import { getNextLinkName } from 'soapbox/utils/quirks'; - -import api, { getLinks } from '../api'; - -import { fetchRelationships } from './accounts'; -import { importFetchedAccounts } from './importer'; import { openModal } from './modals'; -import type { AxiosError } from 'axios'; -import type { Account as AccountEntity } from 'soapbox/schemas'; -import type { AppDispatch, RootState } from 'soapbox/store'; -import type { APIEntity } from 'soapbox/types/entities'; - -const MUTES_FETCH_REQUEST = 'MUTES_FETCH_REQUEST'; -const MUTES_FETCH_SUCCESS = 'MUTES_FETCH_SUCCESS'; -const MUTES_FETCH_FAIL = 'MUTES_FETCH_FAIL'; - -const MUTES_EXPAND_REQUEST = 'MUTES_EXPAND_REQUEST'; -const MUTES_EXPAND_SUCCESS = 'MUTES_EXPAND_SUCCESS'; -const MUTES_EXPAND_FAIL = 'MUTES_EXPAND_FAIL'; +import type { AppDispatch } from 'soapbox/store'; +import type { Account as AccountEntity } from 'soapbox/types/entities'; const MUTES_INIT_MODAL = 'MUTES_INIT_MODAL'; const MUTES_TOGGLE_HIDE_NOTIFICATIONS = 'MUTES_TOGGLE_HIDE_NOTIFICATIONS'; const MUTES_CHANGE_DURATION = 'MUTES_CHANGE_DURATION'; -const fetchMutes = () => - (dispatch: AppDispatch, getState: () => RootState) => { - if (!isLoggedIn(getState)) return; - const nextLinkName = getNextLinkName(getState); - - dispatch(fetchMutesRequest()); - - api(getState).get('/api/v1/mutes').then(response => { - const next = getLinks(response).refs.find(link => link.rel === nextLinkName); - dispatch(importFetchedAccounts(response.data)); - dispatch(fetchMutesSuccess(response.data, next ? next.uri : null)); - dispatch(fetchRelationships(response.data.map((item: APIEntity) => item.id))); - }).catch(error => dispatch(fetchMutesFail(error))); - }; - -const fetchMutesRequest = () => ({ - type: MUTES_FETCH_REQUEST, -}); - -const fetchMutesSuccess = (accounts: APIEntity[], next: string | null) => ({ - type: MUTES_FETCH_SUCCESS, - accounts, - next, -}); - -const fetchMutesFail = (error: AxiosError) => ({ - type: MUTES_FETCH_FAIL, - error, -}); - -const expandMutes = () => - (dispatch: AppDispatch, getState: () => RootState) => { - if (!isLoggedIn(getState)) return; - const nextLinkName = getNextLinkName(getState); - - const url = getState().user_lists.mutes.next; - - if (url === null) { - return; - } - - dispatch(expandMutesRequest()); - - api(getState).get(url).then(response => { - const next = getLinks(response).refs.find(link => link.rel === nextLinkName); - dispatch(importFetchedAccounts(response.data)); - dispatch(expandMutesSuccess(response.data, next ? next.uri : null)); - dispatch(fetchRelationships(response.data.map((item: APIEntity) => item.id))); - }).catch(error => dispatch(expandMutesFail(error))); - }; - -const expandMutesRequest = () => ({ - type: MUTES_EXPAND_REQUEST, -}); - -const expandMutesSuccess = (accounts: APIEntity[], next: string | null) => ({ - type: MUTES_EXPAND_SUCCESS, - accounts, - next, -}); - -const expandMutesFail = (error: AxiosError) => ({ - type: MUTES_EXPAND_FAIL, - error, -}); - const initMuteModal = (account: AccountEntity) => (dispatch: AppDispatch) => { dispatch({ @@ -114,23 +31,9 @@ const changeMuteDuration = (duration: number) => }; export { - MUTES_FETCH_REQUEST, - MUTES_FETCH_SUCCESS, - MUTES_FETCH_FAIL, - MUTES_EXPAND_REQUEST, - MUTES_EXPAND_SUCCESS, - MUTES_EXPAND_FAIL, MUTES_INIT_MODAL, MUTES_TOGGLE_HIDE_NOTIFICATIONS, MUTES_CHANGE_DURATION, - fetchMutes, - fetchMutesRequest, - fetchMutesSuccess, - fetchMutesFail, - expandMutes, - expandMutesRequest, - expandMutesSuccess, - expandMutesFail, initMuteModal, toggleHideNotifications, changeMuteDuration, diff --git a/app/soapbox/api/hooks/groups/useGroup.ts b/app/soapbox/api/hooks/groups/useGroup.ts index e963b951b..5eb6147d2 100644 --- a/app/soapbox/api/hooks/groups/useGroup.ts +++ b/app/soapbox/api/hooks/groups/useGroup.ts @@ -11,7 +11,11 @@ function useGroup(groupId: string, refetch = true) { const { entity: group, ...result } = useEntity( [Entities.GROUPS, groupId], () => api.get(`/api/v1/groups/${groupId}`), - { schema: groupSchema, refetch }, + { + schema: groupSchema, + refetch, + enabled: !!groupId, + }, ); const { groupRelationship: relationship } = useGroupRelationship(groupId); diff --git a/app/soapbox/api/hooks/groups/useGroupMutes.ts b/app/soapbox/api/hooks/groups/useGroupMutes.ts new file mode 100644 index 000000000..67eca0772 --- /dev/null +++ b/app/soapbox/api/hooks/groups/useGroupMutes.ts @@ -0,0 +1,25 @@ +import { Entities } from 'soapbox/entity-store/entities'; +import { useEntities } from 'soapbox/entity-store/hooks'; +import { useFeatures } from 'soapbox/hooks'; +import { useApi } from 'soapbox/hooks/useApi'; +import { groupSchema } from 'soapbox/schemas'; + +import type { Group } from 'soapbox/schemas'; + +function useGroupMutes() { + const api = useApi(); + const features = useFeatures(); + + const { entities, ...result } = useEntities( + [Entities.GROUP_MUTES], + () => api.get('/api/v1/groups/mutes'), + { schema: groupSchema, enabled: features.groupsMuting }, + ); + + return { + ...result, + mutes: entities, + }; +} + +export { useGroupMutes }; \ No newline at end of file diff --git a/app/soapbox/api/hooks/groups/useMuteGroup.ts b/app/soapbox/api/hooks/groups/useMuteGroup.ts new file mode 100644 index 000000000..e31c7f4d1 --- /dev/null +++ b/app/soapbox/api/hooks/groups/useMuteGroup.ts @@ -0,0 +1,18 @@ +import { Entities } from 'soapbox/entity-store/entities'; +import { useEntityActions } from 'soapbox/entity-store/hooks'; +import { type Group, groupRelationshipSchema } from 'soapbox/schemas'; + +function useMuteGroup(group?: Group) { + const { createEntity, isSubmitting } = useEntityActions( + [Entities.GROUP_RELATIONSHIPS, group?.id as string], + { post: `/api/v1/groups/${group?.id}/mute` }, + { schema: groupRelationshipSchema }, + ); + + return { + mutate: createEntity, + isSubmitting, + }; +} + +export { useMuteGroup }; \ No newline at end of file diff --git a/app/soapbox/api/hooks/groups/useUnmuteGroup.ts b/app/soapbox/api/hooks/groups/useUnmuteGroup.ts new file mode 100644 index 000000000..6c8768d25 --- /dev/null +++ b/app/soapbox/api/hooks/groups/useUnmuteGroup.ts @@ -0,0 +1,18 @@ +import { Entities } from 'soapbox/entity-store/entities'; +import { useEntityActions } from 'soapbox/entity-store/hooks'; +import { type Group, groupRelationshipSchema } from 'soapbox/schemas'; + +function useUnmuteGroup(group?: Group) { + const { createEntity, isSubmitting } = useEntityActions( + [Entities.GROUP_RELATIONSHIPS, group?.id as string], + { post: `/api/v1/groups/${group?.id}/unmute` }, + { schema: groupRelationshipSchema }, + ); + + return { + mutate: createEntity, + isSubmitting, + }; +} + +export { useUnmuteGroup }; \ No newline at end of file diff --git a/app/soapbox/api/hooks/index.ts b/app/soapbox/api/hooks/index.ts index cdfe0c16f..096c8a065 100644 --- a/app/soapbox/api/hooks/index.ts +++ b/app/soapbox/api/hooks/index.ts @@ -23,6 +23,7 @@ export { useGroupLookup } from './groups/useGroupLookup'; export { useGroupMedia } from './groups/useGroupMedia'; export { useGroupMembers } from './groups/useGroupMembers'; export { useGroupMembershipRequests } from './groups/useGroupMembershipRequests'; +export { useGroupMutes } from './groups/useGroupMutes'; export { useGroupRelationship } from './groups/useGroupRelationship'; export { useGroupRelationships } from './groups/useGroupRelationships'; export { useGroupSearch } from './groups/useGroupSearch'; @@ -32,10 +33,12 @@ export { useGroupValidation } from './groups/useGroupValidation'; export { useGroups } from './groups/useGroups'; export { useGroupsFromTag } from './groups/useGroupsFromTag'; export { useJoinGroup } from './groups/useJoinGroup'; +export { useMuteGroup } from './groups/useMuteGroup'; export { useLeaveGroup } from './groups/useLeaveGroup'; export { usePopularGroups } from './groups/usePopularGroups'; export { usePopularTags } from './groups/usePopularTags'; export { usePromoteGroupMember } from './groups/usePromoteGroupMember'; export { useSuggestedGroups } from './groups/useSuggestedGroups'; +export { useUnmuteGroup } from './groups/useUnmuteGroup'; export { useUpdateGroup } from './groups/useUpdateGroup'; export { useUpdateGroupTag } from './groups/useUpdateGroupTag'; diff --git a/app/soapbox/components/status-action-bar.tsx b/app/soapbox/components/status-action-bar.tsx index b071cc945..813b96444 100644 --- a/app/soapbox/components/status-action-bar.tsx +++ b/app/soapbox/components/status-action-bar.tsx @@ -14,7 +14,7 @@ import { initMuteModal } from 'soapbox/actions/mutes'; import { initReport, ReportableEntities } from 'soapbox/actions/reports'; import { deleteStatus, editStatus, toggleMuteStatus } from 'soapbox/actions/statuses'; import { deleteFromTimelines } from 'soapbox/actions/timelines'; -import { useGroupRelationship } from 'soapbox/api/hooks'; +import { useGroup, useGroupRelationship, useMuteGroup, useUnmuteGroup } from 'soapbox/api/hooks'; import { useDeleteGroupStatus } from 'soapbox/api/hooks/groups/useDeleteGroupStatus'; import DropdownMenu from 'soapbox/components/dropdown-menu'; import StatusActionButton from 'soapbox/components/status-action-button'; @@ -65,12 +65,16 @@ const messages = defineMessages({ mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' }, more: { id: 'status.more', defaultMessage: 'More' }, mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' }, + muteConfirm: { id: 'confirmations.mute_group.confirm', defaultMessage: 'Mute' }, muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' }, + muteGroup: { id: 'group.mute.long_label', defaultMessage: 'Mute Group' }, + muteHeading: { id: 'confirmations.mute_group.heading', defaultMessage: 'Mute Group' }, + muteMessage: { id: 'confirmations.mute_group.message', defaultMessage: 'You are about to mute the group. Do you want to continue?' }, + muteSuccess: { id: 'group.mute.success', defaultMessage: 'Muted the group' }, open: { id: 'status.open', defaultMessage: 'Expand this post' }, pin: { id: 'status.pin', defaultMessage: 'Pin on profile' }, pinToGroup: { id: 'status.pin_to_group', defaultMessage: 'Pin to Group' }, pinToGroupSuccess: { id: 'status.pin_to_group.success', defaultMessage: 'Pinned to Group!' }, - unpinFromGroup: { id: 'status.unpin_to_group', defaultMessage: 'Unpin from Group' }, quotePost: { id: 'status.quote', defaultMessage: 'Quote post' }, reactionCry: { id: 'status.reactions.cry', defaultMessage: 'Sad' }, reactionHeart: { id: 'status.reactions.heart', defaultMessage: 'Love' }, @@ -93,7 +97,10 @@ const messages = defineMessages({ share: { id: 'status.share', defaultMessage: 'Share' }, unbookmark: { id: 'status.unbookmark', defaultMessage: 'Remove bookmark' }, unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' }, + unmuteGroup: { id: 'group.unmute.long_label', defaultMessage: 'Unmute Group' }, + unmuteSuccess: { id: 'group.unmute.success', defaultMessage: 'Unmuted the group' }, unpin: { id: 'status.unpin', defaultMessage: 'Unpin from profile' }, + unpinFromGroup: { id: 'status.unpin_to_group', defaultMessage: 'Unpin from Group' }, }); interface IStatusActionBar { @@ -115,6 +122,12 @@ const StatusActionBar: React.FC = ({ const history = useHistory(); const dispatch = useAppDispatch(); + + const { group } = useGroup((status.group as Group)?.id as string); + const muteGroup = useMuteGroup(group as Group); + const unmuteGroup = useUnmuteGroup(group as Group); + const isMutingGroup = !!group?.relationship?.muting; + const me = useAppSelector(state => state.me); const { groupRelationship } = useGroupRelationship(status.group?.id); const features = useFeatures(); @@ -265,6 +278,27 @@ const StatusActionBar: React.FC = ({ dispatch(initMuteModal(status.account as Account)); }; + const handleMuteGroupClick: React.EventHandler = () => + dispatch(openModal('CONFIRM', { + heading: intl.formatMessage(messages.muteHeading), + message: intl.formatMessage(messages.muteMessage), + confirm: intl.formatMessage(messages.muteConfirm), + confirmationTheme: 'primary', + onConfirm: () => muteGroup.mutate(undefined, { + onSuccess() { + toast.success(intl.formatMessage(messages.muteSuccess)); + }, + }), + })); + + const handleUnmuteGroupClick: React.EventHandler = () => { + unmuteGroup.mutate(undefined, { + onSuccess() { + toast.success(intl.formatMessage(messages.unmuteSuccess)); + }, + }); + }; + const handleBlockClick: React.EventHandler = (e) => { const account = status.get('account') as Account; @@ -472,6 +506,15 @@ const StatusActionBar: React.FC = ({ } menu.push(null); + if (features.groupsMuting && status.group) { + menu.push({ + text: isMutingGroup ? intl.formatMessage(messages.unmuteGroup) : intl.formatMessage(messages.muteGroup), + icon: require('@tabler/icons/volume-3.svg'), + action: isMutingGroup ? handleUnmuteGroupClick : handleMuteGroupClick, + }); + menu.push(null); + } + menu.push({ text: intl.formatMessage(messages.mute, { name: username }), action: handleMuteClick, diff --git a/app/soapbox/entity-store/entities.ts b/app/soapbox/entity-store/entities.ts index 3f40f7e16..8674ebd40 100644 --- a/app/soapbox/entity-store/entities.ts +++ b/app/soapbox/entity-store/entities.ts @@ -4,6 +4,7 @@ enum Entities { ACCOUNTS = 'Accounts', GROUPS = 'Groups', GROUP_MEMBERSHIPS = 'GroupMemberships', + GROUP_MUTES = 'GroupMutes', GROUP_RELATIONSHIPS = 'GroupRelationships', GROUP_TAGS = 'GroupTags', PATRON_USERS = 'PatronUsers', diff --git a/app/soapbox/features/blocks/index.tsx b/app/soapbox/features/blocks/index.tsx index cc3f2ab50..f5bedf96c 100644 --- a/app/soapbox/features/blocks/index.tsx +++ b/app/soapbox/features/blocks/index.tsx @@ -7,7 +7,7 @@ import ScrollableList from 'soapbox/components/scrollable-list'; import { Column, Spinner } from 'soapbox/components/ui'; const messages = defineMessages({ - heading: { id: 'column.blocks', defaultMessage: 'Blocked users' }, + heading: { id: 'column.blocks', defaultMessage: 'Blocks' }, }); const Blocks: React.FC = () => { @@ -37,7 +37,8 @@ const Blocks: React.FC = () => { onLoadMore={fetchNextPage} hasMore={hasNextPage} emptyMessage={emptyMessage} - itemClassName='pb-4' + emptyMessageCard={false} + itemClassName='pb-4 last:pb-0' > {accounts.map((account) => ( diff --git a/app/soapbox/features/group/components/__tests__/group-options-button.test.tsx b/app/soapbox/features/group/components/__tests__/group-options-button.test.tsx index e3171bb81..2745c885a 100644 --- a/app/soapbox/features/group/components/__tests__/group-options-button.test.tsx +++ b/app/soapbox/features/group/components/__tests__/group-options-button.test.tsx @@ -59,10 +59,10 @@ describe('', () => { }); }); - it('should render null', () => { + it('should render one option for muting the group', () => { render(); - expect(screen.queryAllByTestId('dropdown-menu-button')).toHaveLength(0); + expect(screen.queryAllByTestId('dropdown-menu-button')).toHaveLength(1); }); }); diff --git a/app/soapbox/features/group/components/group-options-button.tsx b/app/soapbox/features/group/components/group-options-button.tsx index 1f4373056..cdcaa3565 100644 --- a/app/soapbox/features/group/components/group-options-button.tsx +++ b/app/soapbox/features/group/components/group-options-button.tsx @@ -3,7 +3,7 @@ import { defineMessages, useIntl } from 'react-intl'; import { openModal } from 'soapbox/actions/modals'; import { initReport, ReportableEntities } from 'soapbox/actions/reports'; -import { useLeaveGroup } from 'soapbox/api/hooks'; +import { useLeaveGroup, useMuteGroup, useUnmuteGroup } from 'soapbox/api/hooks'; import DropdownMenu, { Menu } from 'soapbox/components/dropdown-menu'; import { IconButton } from 'soapbox/components/ui'; import { useAppDispatch, useOwnAccount } from 'soapbox/hooks'; @@ -14,10 +14,17 @@ import type { Account, Group } from 'soapbox/types/entities'; const messages = defineMessages({ confirmationConfirm: { id: 'confirmations.leave_group.confirm', defaultMessage: 'Leave' }, - confirmationHeading: { id: 'confirmations.leave_group.heading', defaultMessage: 'Leave group' }, + confirmationHeading: { id: 'confirmations.leave_group.heading', defaultMessage: 'Leave Group' }, confirmationMessage: { id: 'confirmations.leave_group.message', defaultMessage: 'You are about to leave the group. Do you want to continue?' }, + muteConfirm: { id: 'confirmations.mute_group.confirm', defaultMessage: 'Mute' }, + muteHeading: { id: 'confirmations.mute_group.heading', defaultMessage: 'Mute Group' }, + muteMessage: { id: 'confirmations.mute_group.message', defaultMessage: 'You are about to mute the group. Do you want to continue?' }, + muteSuccess: { id: 'group.mute.success', defaultMessage: 'Muted the group' }, + unmuteSuccess: { id: 'group.unmute.success', defaultMessage: 'Unmuted the group' }, leave: { id: 'group.leave.label', defaultMessage: 'Leave' }, leaveSuccess: { id: 'group.leave.success', defaultMessage: 'Left the group' }, + mute: { id: 'group.mute.label', defaultMessage: 'Mute' }, + unmute: { id: 'group.unmute.label', defaultMessage: 'Unmute' }, report: { id: 'group.report.label', defaultMessage: 'Report' }, share: { id: 'group.share.label', defaultMessage: 'Share' }, }); @@ -30,11 +37,16 @@ const GroupOptionsButton = ({ group }: IGroupActionButton) => { const { account } = useOwnAccount(); const dispatch = useAppDispatch(); const intl = useIntl(); + + const muteGroup = useMuteGroup(group); + const unmuteGroup = useUnmuteGroup(group); const leaveGroup = useLeaveGroup(group); const isMember = group.relationship?.role === GroupRoles.USER; const isAdmin = group.relationship?.role === GroupRoles.ADMIN; + const isInGroup = !!group.relationship?.member; const isBlocked = group.relationship?.blocked_by; + const isMuting = group.relationship?.muting; const handleShare = () => { navigator.share({ @@ -45,7 +57,28 @@ const GroupOptionsButton = ({ group }: IGroupActionButton) => { }); }; - const onLeaveGroup = () => + const handleMute = () => + dispatch(openModal('CONFIRM', { + heading: intl.formatMessage(messages.muteHeading), + message: intl.formatMessage(messages.muteMessage), + confirm: intl.formatMessage(messages.muteConfirm), + confirmationTheme: 'primary', + onConfirm: () => muteGroup.mutate(undefined, { + onSuccess() { + toast.success(intl.formatMessage(messages.muteSuccess)); + }, + }), + })); + + const handleUnmute = () => { + unmuteGroup.mutate(undefined, { + onSuccess() { + toast.success(intl.formatMessage(messages.unmuteSuccess)); + }, + }); + }; + + const handleLeave = () => dispatch(openModal('CONFIRM', { heading: intl.formatMessage(messages.confirmationHeading), message: intl.formatMessage(messages.confirmationMessage), @@ -62,14 +95,6 @@ const GroupOptionsButton = ({ group }: IGroupActionButton) => { const canShare = 'share' in navigator; const items = []; - if (isMember || isAdmin) { - items.push({ - text: intl.formatMessage(messages.report), - icon: require('@tabler/icons/flag.svg'), - action: () => dispatch(initReport(ReportableEntities.GROUP, account as Account, { group })), - }); - } - if (canShare) { items.push({ text: intl.formatMessage(messages.share), @@ -78,16 +103,33 @@ const GroupOptionsButton = ({ group }: IGroupActionButton) => { }); } + if (isInGroup) { + items.push({ + text: isMuting ? intl.formatMessage(messages.unmute) : intl.formatMessage(messages.mute), + icon: require('@tabler/icons/volume-3.svg'), + action: isMuting ? handleUnmute : handleMute, + }); + } + + if (isMember || isAdmin) { + items.push({ + text: intl.formatMessage(messages.report), + icon: require('@tabler/icons/flag.svg'), + action: () => dispatch(initReport(ReportableEntities.GROUP, account as Account, { group })), + }); + } + if (isAdmin) { + items.push(null); items.push({ text: intl.formatMessage(messages.leave), icon: require('@tabler/icons/logout.svg'), - action: onLeaveGroup, + action: handleLeave, }); } return items; - }, [isMember, isAdmin]); + }, [isMember, isAdmin, isInGroup, isMuting]); if (isBlocked || menu.length === 0) { return null; diff --git a/app/soapbox/features/groups/components/discover/group-list-item.tsx b/app/soapbox/features/groups/components/discover/group-list-item.tsx index 6331d9d05..f765a1983 100644 --- a/app/soapbox/features/groups/components/discover/group-list-item.tsx +++ b/app/soapbox/features/groups/components/discover/group-list-item.tsx @@ -8,12 +8,12 @@ import GroupActionButton from 'soapbox/features/group/components/group-action-bu import { Group as GroupEntity } from 'soapbox/types/entities'; import { shortNumberFormat } from 'soapbox/utils/numbers'; -interface IGroup { +interface IGroupListItem { group: GroupEntity withJoinAction?: boolean } -const GroupListItem = (props: IGroup) => { +const GroupListItem = (props: IGroupListItem) => { const { group, withJoinAction = true } = props; return ( diff --git a/app/soapbox/features/mutes/components/group-list-item.tsx b/app/soapbox/features/mutes/components/group-list-item.tsx new file mode 100644 index 000000000..95f644b5a --- /dev/null +++ b/app/soapbox/features/mutes/components/group-list-item.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import { FormattedMessage, defineMessages, useIntl } from 'react-intl'; + +import { useUnmuteGroup } from 'soapbox/api/hooks'; +import GroupAvatar from 'soapbox/components/groups/group-avatar'; +import { Button, HStack, Text } from 'soapbox/components/ui'; +import { type Group } from 'soapbox/schemas'; +import toast from 'soapbox/toast'; + +interface IGroupListItem { + group: Group + onUnmute(): void +} + +const messages = defineMessages({ + unmuteSuccess: { id: 'group.unmute.success', defaultMessage: 'Unmuted the group' }, +}); + +const GroupListItem = ({ group, onUnmute }: IGroupListItem) => { + const intl = useIntl(); + + const unmuteGroup = useUnmuteGroup(group); + + const handleUnmute = () => { + unmuteGroup.mutate(undefined, { + onSuccess() { + onUnmute(); + toast.success(intl.formatMessage(messages.unmuteSuccess)); + }, + }); + }; + + return ( + + + + + + + + + + ); +}; + +export default GroupListItem; \ No newline at end of file diff --git a/app/soapbox/features/mutes/index.tsx b/app/soapbox/features/mutes/index.tsx index 1bb83d0b3..c7c25bf35 100644 --- a/app/soapbox/features/mutes/index.tsx +++ b/app/soapbox/features/mutes/index.tsx @@ -1,48 +1,105 @@ -import React from 'react'; +import React, { useState } from 'react'; import { defineMessages, useIntl, FormattedMessage } from 'react-intl'; -import { useMutes } from 'soapbox/api/hooks'; -import Account from 'soapbox/components/account'; +import { useMutes, useGroupMutes } from 'soapbox/api/hooks'; import ScrollableList from 'soapbox/components/scrollable-list'; -import { Column, Spinner } from 'soapbox/components/ui'; +import { Column, Stack, Tabs } from 'soapbox/components/ui'; +import AccountContainer from 'soapbox/containers/account-container'; +import { useFeatures } from 'soapbox/hooks'; + +import GroupListItem from './components/group-list-item'; const messages = defineMessages({ - heading: { id: 'column.mutes', defaultMessage: 'Muted users' }, + heading: { id: 'column.mutes', defaultMessage: 'Mutes' }, }); +enum TabItems { + ACCOUNTS = 'ACCOUNTS', + GROUPS = 'GROUPS' +} + const Mutes: React.FC = () => { const intl = useIntl(); + const features = useFeatures(); const { accounts, - hasNextPage, - fetchNextPage, - isLoading, + hasNextPage: hasNextAccountsPage, + fetchNextPage: fetchNextAccounts, + isLoading: isLoadingAccounts, } = useMutes(); - if (isLoading) { - return ( - - - - ); - } + const { + mutes: groupMutes, + isLoading: isLoadingGroups, + hasNextPage: hasNextGroupsPage, + fetchNextPage: fetchNextGroups, + fetchEntities: fetchMutedGroups, + } = useGroupMutes(); - const emptyMessage = ; + const [activeItem, setActiveItem] = useState(TabItems.ACCOUNTS); + const isAccountsTabSelected = activeItem === TabItems.ACCOUNTS; + + const scrollableListProps = { + itemClassName: 'pb-4 last:pb-0', + scrollKey: 'mutes', + emptyMessageCard: false, + }; return ( - - {accounts.map((account) => ( - - ))} - + + {features.groupsMuting && ( + setActiveItem(TabItems.ACCOUNTS), + name: TabItems.ACCOUNTS, + }, + { + text: 'Groups', + action: () => setActiveItem(TabItems.GROUPS), + name: TabItems.GROUPS, + }, + ]} + activeItem={activeItem} + /> + )} + + {isAccountsTabSelected ? ( + + } + > + {accounts.map((accounts) => + , + )} + + ) : ( + + } + > + {groupMutes.map((group) =>( + + ))} + + )} + ); }; diff --git a/app/soapbox/features/settings/index.tsx b/app/soapbox/features/settings/index.tsx index 8c9877de4..96b349bd4 100644 --- a/app/soapbox/features/settings/index.tsx +++ b/app/soapbox/features/settings/index.tsx @@ -12,24 +12,27 @@ import Preferences from '../preferences'; import MessagesSettings from './components/messages-settings'; const messages = defineMessages({ - settings: { id: 'settings.settings', defaultMessage: 'Settings' }, - profile: { id: 'settings.profile', defaultMessage: 'Profile' }, - security: { id: 'settings.security', defaultMessage: 'Security' }, - preferences: { id: 'settings.preferences', defaultMessage: 'Preferences' }, - editProfile: { id: 'settings.edit_profile', defaultMessage: 'Edit Profile' }, + accountAliases: { id: 'navigation_bar.account_aliases', defaultMessage: 'Account aliases' }, + accountMigration: { id: 'settings.account_migration', defaultMessage: 'Move Account' }, + backups: { id: 'column.backups', defaultMessage: 'Backups' }, + blocks: { id: 'settings.blocks', defaultMessage: 'Blocks' }, changeEmail: { id: 'settings.change_email', defaultMessage: 'Change Email' }, changePassword: { id: 'settings.change_password', defaultMessage: 'Change Password' }, configureMfa: { id: 'settings.configure_mfa', defaultMessage: 'Configure MFA' }, - sessions: { id: 'settings.sessions', defaultMessage: 'Active sessions' }, deleteAccount: { id: 'settings.delete_account', defaultMessage: 'Delete Account' }, - accountMigration: { id: 'settings.account_migration', defaultMessage: 'Move Account' }, - accountAliases: { id: 'navigation_bar.account_aliases', defaultMessage: 'Account aliases' }, - other: { id: 'settings.other', defaultMessage: 'Other options' }, - mfaEnabled: { id: 'mfa.enabled', defaultMessage: 'Enabled' }, - mfaDisabled: { id: 'mfa.disabled', defaultMessage: 'Disabled' }, - backups: { id: 'column.backups', defaultMessage: 'Backups' }, - importData: { id: 'navigation_bar.import_data', defaultMessage: 'Import data' }, + editProfile: { id: 'settings.edit_profile', defaultMessage: 'Edit Profile' }, exportData: { id: 'column.export_data', defaultMessage: 'Export data' }, + importData: { id: 'navigation_bar.import_data', defaultMessage: 'Import data' }, + mfaDisabled: { id: 'mfa.disabled', defaultMessage: 'Disabled' }, + mfaEnabled: { id: 'mfa.enabled', defaultMessage: 'Enabled' }, + mutes: { id: 'settings.mutes', defaultMessage: 'Mutes' }, + other: { id: 'settings.other', defaultMessage: 'Other options' }, + preferences: { id: 'settings.preferences', defaultMessage: 'Preferences' }, + privacy: { id: 'settings.privacy', defaultMessage: 'Privacy' }, + profile: { id: 'settings.profile', defaultMessage: 'Profile' }, + security: { id: 'settings.security', defaultMessage: 'Security' }, + sessions: { id: 'settings.sessions', defaultMessage: 'Active sessions' }, + settings: { id: 'settings.settings', defaultMessage: 'Settings' }, }); /** User settings page. */ @@ -53,6 +56,8 @@ const Settings = () => { const navigateToBackups = () => history.push('/settings/backups'); const navigateToImportData = () => history.push('/settings/import'); const navigateToExportData = () => history.push('/settings/export'); + const navigateToMutes = () => history.push('/mutes'); + const navigateToBlocks = () => history.push('/blocks'); const isMfaEnabled = mfa.getIn(['settings', 'totp']); @@ -79,6 +84,17 @@ const Settings = () => { + + + + + + + + + + + {(features.security || features.sessions) && ( <> diff --git a/app/soapbox/locales/en.json b/app/soapbox/locales/en.json index 73942ff9f..d863da71e 100644 --- a/app/soapbox/locales/en.json +++ b/app/soapbox/locales/en.json @@ -308,7 +308,7 @@ "column.app_create": "Create app", "column.backups": "Backups", "column.birthdays": "Birthdays", - "column.blocks": "Blocked users", + "column.blocks": "Blocks", "column.bookmarks": "Bookmarks", "column.chats": "Chats", "column.community": "Local timeline", @@ -367,7 +367,7 @@ "column.mfa_disable_button": "Disable", "column.mfa_setup": "Proceed to Setup", "column.migration": "Account migration", - "column.mutes": "Muted users", + "column.mutes": "Mutes", "column.notifications": "Notifications", "column.pins": "Pinned posts", "column.preferences": "Preferences", @@ -510,6 +510,9 @@ "confirmations.mute.confirm": "Mute", "confirmations.mute.heading": "Mute @{name}", "confirmations.mute.message": "Are you sure you want to mute {name}?", + "confirmations.mute_group.confirm": "Mute", + "confirmations.mute_group.heading": "Mute Group", + "confirmations.mute_group.message": "You are about to mute the group. Do you want to continue?", "confirmations.redraft.confirm": "Delete & redraft", "confirmations.redraft.heading": "Delete & redraft", "confirmations.redraft.message": "Are you sure you want to delete this post and re-draft it? Favorites and reposts will be lost, and replies to the original post will be orphaned.", @@ -793,6 +796,9 @@ "group.manage": "Manage Group", "group.member.admin.limit.summary": "You can assign up to {count, plural, one {admin} other {admins}} for the group at this time.", "group.member.admin.limit.title": "Admin limit reached", + "group.mute.label": "Mute", + "group.mute.long_label": "Mute Group", + "group.mute.success": "Muted the group", "group.popover.action": "View Group", "group.popover.summary": "You must be a member of the group in order to reply to this status.", "group.popover.title": "Membership required", @@ -826,6 +832,9 @@ "group.tags.unpin": "Unpin topic", "group.tags.unpin.success": "Unpinned!", "group.tags.visible.success": "Topic marked as visible", + "group.unmute.label": "Unmute", + "group.unmute.long_label": "Unmute Group", + "group.unmute.success": "Unmuted the group", "group.update.success": "Group successfully saved", "group.upload_banner": "Upload photo", "groups.discover.popular.empty": "Unable to fetch popular groups at this time. Please check back later.", @@ -1039,6 +1048,7 @@ "mute_modal.auto_expire": "Automatically expire mute?", "mute_modal.duration": "Duration", "mute_modal.hide_notifications": "Hide notifications from this user?", + "mutes.empty.groups": "You haven't muted any groups yet.", "navbar.login.action": "Log in", "navbar.login.email.placeholder": "E-mail address", "navbar.login.forgot_password": "Forgot password?", @@ -1351,14 +1361,17 @@ "security.update_password.fail": "Update password failed.", "security.update_password.success": "Password successfully updated.", "settings.account_migration": "Move Account", + "settings.blocks": "Blocks", "settings.change_email": "Change Email", "settings.change_password": "Change Password", "settings.configure_mfa": "Configure MFA", "settings.delete_account": "Delete Account", "settings.edit_profile": "Edit Profile", "settings.messages.label": "Allow users to start a new chat with you", + "settings.mutes": "Mutes", "settings.other": "Other Options", "settings.preferences": "Preferences", + "settings.privacy": "Privacy", "settings.profile": "Profile", "settings.save.success": "Your preferences have been saved!", "settings.security": "Security", diff --git a/app/soapbox/normalizers/group-relationship.ts b/app/soapbox/normalizers/group-relationship.ts index 786295fe3..dfb6196fc 100644 --- a/app/soapbox/normalizers/group-relationship.ts +++ b/app/soapbox/normalizers/group-relationship.ts @@ -16,6 +16,7 @@ export const GroupRelationshipRecord = ImmutableRecord({ member: false, notifying: null, requested: false, + muting: false, role: 'user' as GroupRoles, pending_requests: false, }); diff --git a/app/soapbox/reducers/user-lists.ts b/app/soapbox/reducers/user-lists.ts index 80a5fbafc..f5b7da7c0 100644 --- a/app/soapbox/reducers/user-lists.ts +++ b/app/soapbox/reducers/user-lists.ts @@ -65,10 +65,6 @@ import { DISLIKES_FETCH_SUCCESS, REACTIONS_FETCH_SUCCESS, } from 'soapbox/actions/interactions'; -import { - MUTES_FETCH_SUCCESS, - MUTES_EXPAND_SUCCESS, -} from 'soapbox/actions/mutes'; import { NOTIFICATIONS_UPDATE, } from 'soapbox/actions/notifications'; @@ -203,10 +199,6 @@ export default function userLists(state = ReducerRecord(), action: AnyAction) { return normalizeList(state, ['blocks'], action.accounts, action.next); case BLOCKS_EXPAND_SUCCESS: return appendToList(state, ['blocks'], action.accounts, action.next); - case MUTES_FETCH_SUCCESS: - return normalizeList(state, ['mutes'], action.accounts, action.next); - case MUTES_EXPAND_SUCCESS: - return appendToList(state, ['mutes'], action.accounts, action.next); case DIRECTORY_FETCH_SUCCESS: return normalizeList(state, ['directory'], action.accounts, action.next); case DIRECTORY_EXPAND_SUCCESS: diff --git a/app/soapbox/schemas/group-relationship.ts b/app/soapbox/schemas/group-relationship.ts index baeb55a12..93e2a5e14 100644 --- a/app/soapbox/schemas/group-relationship.ts +++ b/app/soapbox/schemas/group-relationship.ts @@ -3,13 +3,14 @@ import z from 'zod'; import { GroupRoles } from './group-member'; const groupRelationshipSchema = z.object({ + blocked_by: z.boolean().catch(false), id: z.string(), member: z.boolean().catch(false), - requested: z.boolean().catch(false), - role: z.nativeEnum(GroupRoles).catch(GroupRoles.USER), - blocked_by: z.boolean().catch(false), + muting: z.boolean().nullable().catch(false), notifying: z.boolean().nullable().catch(null), pending_requests: z.boolean().catch(false), + requested: z.boolean().catch(false), + role: z.nativeEnum(GroupRoles).catch(GroupRoles.USER), }); type GroupRelationship = z.infer; diff --git a/app/soapbox/utils/features.ts b/app/soapbox/utils/features.ts index 429dd2a1f..77ba331cb 100644 --- a/app/soapbox/utils/features.ts +++ b/app/soapbox/utils/features.ts @@ -567,6 +567,11 @@ const getInstanceFeatures = (instance: Instance) => { */ groupsKick: v.software !== TRUTHSOCIAL, + /** + * Can mute a Group. + */ + groupsMuting: v.software === TRUTHSOCIAL, + /** * Can query pending Group requests. */