Merge branch 'group-mutes' into 'develop'

Support Group Muting

See merge request soapbox-pub/soapbox!2552
This commit is contained in:
Chewbacca 2023-06-27 19:14:11 +00:00
commit e60a4b5be0
23 changed files with 418 additions and 184 deletions

View file

@ -1,96 +1,14 @@
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 { openModal } from './modals';
import type { AxiosError } from 'axios'; import type { Account } from 'soapbox/schemas';
import type { Account as AccountEntity } from 'soapbox/schemas'; import type { AppDispatch } from 'soapbox/store';
import type { AppDispatch, RootState } from 'soapbox/store'; import type { Account as AccountEntity } from 'soapbox/types/entities';
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';
const MUTES_INIT_MODAL = 'MUTES_INIT_MODAL'; const MUTES_INIT_MODAL = 'MUTES_INIT_MODAL';
const MUTES_TOGGLE_HIDE_NOTIFICATIONS = 'MUTES_TOGGLE_HIDE_NOTIFICATIONS'; const MUTES_TOGGLE_HIDE_NOTIFICATIONS = 'MUTES_TOGGLE_HIDE_NOTIFICATIONS';
const MUTES_CHANGE_DURATION = 'MUTES_CHANGE_DURATION'; const MUTES_CHANGE_DURATION = 'MUTES_CHANGE_DURATION';
const fetchMutes = () => const initMuteModal = (account: AccountEntity | Account) =>
(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: AppDispatch) => {
dispatch({ dispatch({
type: MUTES_INIT_MODAL, type: MUTES_INIT_MODAL,
@ -114,23 +32,9 @@ const changeMuteDuration = (duration: number) =>
}; };
export { export {
MUTES_FETCH_REQUEST,
MUTES_FETCH_SUCCESS,
MUTES_FETCH_FAIL,
MUTES_EXPAND_REQUEST,
MUTES_EXPAND_SUCCESS,
MUTES_EXPAND_FAIL,
MUTES_INIT_MODAL, MUTES_INIT_MODAL,
MUTES_TOGGLE_HIDE_NOTIFICATIONS, MUTES_TOGGLE_HIDE_NOTIFICATIONS,
MUTES_CHANGE_DURATION, MUTES_CHANGE_DURATION,
fetchMutes,
fetchMutesRequest,
fetchMutesSuccess,
fetchMutesFail,
expandMutes,
expandMutesRequest,
expandMutesSuccess,
expandMutesFail,
initMuteModal, initMuteModal,
toggleHideNotifications, toggleHideNotifications,
changeMuteDuration, changeMuteDuration,

View file

@ -1,12 +1,12 @@
import { Entities } from 'soapbox/entity-store/entities'; import { Entities } from 'soapbox/entity-store/entities';
import { useEntityActions } from 'soapbox/entity-store/hooks'; import { useEntityActions } from 'soapbox/entity-store/hooks';
import type { Group, GroupMember } from 'soapbox/schemas'; import type { Account, Group, GroupMember } from 'soapbox/schemas';
function useBlockGroupMember(group: Group, groupMember: GroupMember) { function useBlockGroupMember(group: Group, account: Account) {
const { createEntity } = useEntityActions<GroupMember>( const { createEntity } = useEntityActions<GroupMember>(
[Entities.GROUP_MEMBERSHIPS, groupMember.id], [Entities.GROUP_MEMBERSHIPS, account.id],
{ post: `/api/v1/groups/${group.id}/blocks` }, { post: `/api/v1/groups/${group?.id}/blocks` },
); );
return createEntity; return createEntity;

View file

@ -11,7 +11,11 @@ function useGroup(groupId: string, refetch = true) {
const { entity: group, ...result } = useEntity<Group>( const { entity: group, ...result } = useEntity<Group>(
[Entities.GROUPS, groupId], [Entities.GROUPS, groupId],
() => api.get(`/api/v1/groups/${groupId}`), () => api.get(`/api/v1/groups/${groupId}`),
{ schema: groupSchema, refetch }, {
schema: groupSchema,
refetch,
enabled: !!groupId,
},
); );
const { groupRelationship: relationship } = useGroupRelationship(groupId); const { groupRelationship: relationship } = useGroupRelationship(groupId);

View file

@ -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<Group>(
[Entities.GROUP_MUTES],
() => api.get('/api/v1/groups/mutes'),
{ schema: groupSchema, enabled: features.groupsMuting },
);
return {
...result,
mutes: entities,
};
}
export { useGroupMutes };

View file

@ -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 };

View file

@ -8,7 +8,7 @@ import type { Group, GroupMember } from 'soapbox/schemas';
function usePromoteGroupMember(group: Group, groupMember: GroupMember) { function usePromoteGroupMember(group: Group, groupMember: GroupMember) {
const { createEntity } = useEntityActions<GroupMember>( const { createEntity } = useEntityActions<GroupMember>(
[Entities.GROUP_MEMBERSHIPS, groupMember.id], [Entities.GROUP_MEMBERSHIPS, groupMember.account.id],
{ post: `/api/v1/groups/${group.id}/promote` }, { post: `/api/v1/groups/${group.id}/promote` },
{ schema: z.array(groupMemberSchema).transform((arr) => arr[0]) }, { schema: z.array(groupMemberSchema).transform((arr) => arr[0]) },
); );

View file

@ -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 };

View file

@ -23,6 +23,7 @@ export { useGroupLookup } from './groups/useGroupLookup';
export { useGroupMedia } from './groups/useGroupMedia'; export { useGroupMedia } from './groups/useGroupMedia';
export { useGroupMembers } from './groups/useGroupMembers'; export { useGroupMembers } from './groups/useGroupMembers';
export { useGroupMembershipRequests } from './groups/useGroupMembershipRequests'; export { useGroupMembershipRequests } from './groups/useGroupMembershipRequests';
export { useGroupMutes } from './groups/useGroupMutes';
export { useGroupRelationship } from './groups/useGroupRelationship'; export { useGroupRelationship } from './groups/useGroupRelationship';
export { useGroupRelationships } from './groups/useGroupRelationships'; export { useGroupRelationships } from './groups/useGroupRelationships';
export { useGroupSearch } from './groups/useGroupSearch'; export { useGroupSearch } from './groups/useGroupSearch';
@ -32,10 +33,12 @@ export { useGroupValidation } from './groups/useGroupValidation';
export { useGroups } from './groups/useGroups'; export { useGroups } from './groups/useGroups';
export { useGroupsFromTag } from './groups/useGroupsFromTag'; export { useGroupsFromTag } from './groups/useGroupsFromTag';
export { useJoinGroup } from './groups/useJoinGroup'; export { useJoinGroup } from './groups/useJoinGroup';
export { useMuteGroup } from './groups/useMuteGroup';
export { useLeaveGroup } from './groups/useLeaveGroup'; export { useLeaveGroup } from './groups/useLeaveGroup';
export { usePopularGroups } from './groups/usePopularGroups'; export { usePopularGroups } from './groups/usePopularGroups';
export { usePopularTags } from './groups/usePopularTags'; export { usePopularTags } from './groups/usePopularTags';
export { usePromoteGroupMember } from './groups/usePromoteGroupMember'; export { usePromoteGroupMember } from './groups/usePromoteGroupMember';
export { useSuggestedGroups } from './groups/useSuggestedGroups'; export { useSuggestedGroups } from './groups/useSuggestedGroups';
export { useUnmuteGroup } from './groups/useUnmuteGroup';
export { useUpdateGroup } from './groups/useUpdateGroup'; export { useUpdateGroup } from './groups/useUpdateGroup';
export { useUpdateGroupTag } from './groups/useUpdateGroupTag'; export { useUpdateGroupTag } from './groups/useUpdateGroupTag';

View file

@ -1,7 +1,7 @@
import { List as ImmutableList } from 'immutable'; import { List as ImmutableList } from 'immutable';
import React from 'react'; import React from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { useHistory } from 'react-router-dom'; import { useHistory, useRouteMatch } from 'react-router-dom';
import { blockAccount } from 'soapbox/actions/accounts'; import { blockAccount } from 'soapbox/actions/accounts';
import { launchChat } from 'soapbox/actions/chats'; import { launchChat } from 'soapbox/actions/chats';
@ -14,7 +14,7 @@ import { initMuteModal } from 'soapbox/actions/mutes';
import { initReport, ReportableEntities } from 'soapbox/actions/reports'; import { initReport, ReportableEntities } from 'soapbox/actions/reports';
import { deleteStatus, editStatus, toggleMuteStatus } from 'soapbox/actions/statuses'; import { deleteStatus, editStatus, toggleMuteStatus } from 'soapbox/actions/statuses';
import { deleteFromTimelines } from 'soapbox/actions/timelines'; import { deleteFromTimelines } from 'soapbox/actions/timelines';
import { useGroupRelationship } from 'soapbox/api/hooks'; import { useBlockGroupMember, useGroup, useGroupRelationship, useMuteGroup, useUnmuteGroup } from 'soapbox/api/hooks';
import { useDeleteGroupStatus } from 'soapbox/api/hooks/groups/useDeleteGroupStatus'; import { useDeleteGroupStatus } from 'soapbox/api/hooks/groups/useDeleteGroupStatus';
import DropdownMenu from 'soapbox/components/dropdown-menu'; import DropdownMenu from 'soapbox/components/dropdown-menu';
import StatusActionButton from 'soapbox/components/status-action-button'; import StatusActionButton from 'soapbox/components/status-action-button';
@ -36,6 +36,7 @@ const messages = defineMessages({
adminAccount: { id: 'status.admin_account', defaultMessage: 'Moderate @{name}' }, adminAccount: { id: 'status.admin_account', defaultMessage: 'Moderate @{name}' },
admin_status: { id: 'status.admin_status', defaultMessage: 'Open this post in the moderation interface' }, admin_status: { id: 'status.admin_status', defaultMessage: 'Open this post in the moderation interface' },
block: { id: 'account.block', defaultMessage: 'Block @{name}' }, block: { id: 'account.block', defaultMessage: 'Block @{name}' },
blocked: { id: 'group.group_mod_block.success', defaultMessage: '@{name} is banned' },
blockAndReport: { id: 'confirmations.block.block_and_report', defaultMessage: 'Block & Report' }, blockAndReport: { id: 'confirmations.block.block_and_report', defaultMessage: 'Block & Report' },
blockConfirm: { id: 'confirmations.block.confirm', defaultMessage: 'Block' }, blockConfirm: { id: 'confirmations.block.confirm', defaultMessage: 'Block' },
bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' }, bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' },
@ -57,6 +58,9 @@ const messages = defineMessages({
embed: { id: 'status.embed', defaultMessage: 'Embed' }, embed: { id: 'status.embed', defaultMessage: 'Embed' },
external: { id: 'status.external', defaultMessage: 'View post on {domain}' }, external: { id: 'status.external', defaultMessage: 'View post on {domain}' },
favourite: { id: 'status.favourite', defaultMessage: 'Like' }, favourite: { id: 'status.favourite', defaultMessage: 'Like' },
groupBlockConfirm: { id: 'confirmations.block_from_group.confirm', defaultMessage: 'Ban' },
groupBlockFromGroupHeading: { id: 'confirmations.block_from_group.heading', defaultMessage: 'Ban From Group' },
groupBlockFromGroupMessage: { id: 'confirmations.block_from_group.message', defaultMessage: 'Are you sure you want to ban @{name} from the group?' },
groupModDelete: { id: 'status.group_mod_delete', defaultMessage: 'Delete post from group' }, groupModDelete: { id: 'status.group_mod_delete', defaultMessage: 'Delete post from group' },
group_remove_account: { id: 'status.remove_account_from_group', defaultMessage: 'Remove account from group' }, group_remove_account: { id: 'status.remove_account_from_group', defaultMessage: 'Remove account from group' },
group_remove_post: { id: 'status.remove_post_from_group', defaultMessage: 'Remove post from group' }, group_remove_post: { id: 'status.remove_post_from_group', defaultMessage: 'Remove post from group' },
@ -65,12 +69,16 @@ const messages = defineMessages({
mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' }, mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' },
more: { id: 'status.more', defaultMessage: 'More' }, more: { id: 'status.more', defaultMessage: 'More' },
mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' }, mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' },
muteConfirm: { id: 'confirmations.mute_group.confirm', defaultMessage: 'Mute' },
muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' }, 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' }, open: { id: 'status.open', defaultMessage: 'Expand this post' },
pin: { id: 'status.pin', defaultMessage: 'Pin on profile' }, pin: { id: 'status.pin', defaultMessage: 'Pin on profile' },
pinToGroup: { id: 'status.pin_to_group', defaultMessage: 'Pin to Group' }, pinToGroup: { id: 'status.pin_to_group', defaultMessage: 'Pin to Group' },
pinToGroupSuccess: { id: 'status.pin_to_group.success', defaultMessage: 'Pinned 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' }, quotePost: { id: 'status.quote', defaultMessage: 'Quote post' },
reactionCry: { id: 'status.reactions.cry', defaultMessage: 'Sad' }, reactionCry: { id: 'status.reactions.cry', defaultMessage: 'Sad' },
reactionHeart: { id: 'status.reactions.heart', defaultMessage: 'Love' }, reactionHeart: { id: 'status.reactions.heart', defaultMessage: 'Love' },
@ -93,7 +101,10 @@ const messages = defineMessages({
share: { id: 'status.share', defaultMessage: 'Share' }, share: { id: 'status.share', defaultMessage: 'Share' },
unbookmark: { id: 'status.unbookmark', defaultMessage: 'Remove bookmark' }, unbookmark: { id: 'status.unbookmark', defaultMessage: 'Remove bookmark' },
unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' }, 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' }, unpin: { id: 'status.unpin', defaultMessage: 'Unpin from profile' },
unpinFromGroup: { id: 'status.unpin_to_group', defaultMessage: 'Unpin from Group' },
}); });
interface IStatusActionBar { interface IStatusActionBar {
@ -114,13 +125,20 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
const intl = useIntl(); const intl = useIntl();
const history = useHistory(); const history = useHistory();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const match = useRouteMatch<{ groupSlug: string }>('/group/:groupSlug');
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 deleteGroupStatus = useDeleteGroupStatus(group as Group, status.id);
const blockGroupMember = useBlockGroupMember(group as Group, status?.account as any);
const me = useAppSelector(state => state.me); const me = useAppSelector(state => state.me);
const { groupRelationship } = useGroupRelationship(status.group?.id); const { groupRelationship } = useGroupRelationship(status.group?.id);
const features = useFeatures(); const features = useFeatures();
const settings = useSettings(); const settings = useSettings();
const soapboxConfig = useSoapboxConfig(); const soapboxConfig = useSoapboxConfig();
const deleteGroupStatus = useDeleteGroupStatus(status?.group as Group, status.id);
const { allowedEmoji } = soapboxConfig; const { allowedEmoji } = soapboxConfig;
@ -265,6 +283,27 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
dispatch(initMuteModal(status.account as Account)); dispatch(initMuteModal(status.account as Account));
}; };
const handleMuteGroupClick: React.EventHandler<React.MouseEvent> = () =>
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<React.MouseEvent> = () => {
unmuteGroup.mutate(undefined, {
onSuccess() {
toast.success(intl.formatMessage(messages.unmuteSuccess));
},
});
};
const handleBlockClick: React.EventHandler<React.MouseEvent> = (e) => { const handleBlockClick: React.EventHandler<React.MouseEvent> = (e) => {
const account = status.get('account') as Account; const account = status.get('account') as Account;
@ -337,6 +376,21 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
})); }));
}; };
const handleBlockFromGroup = () => {
dispatch(openModal('CONFIRM', {
heading: intl.formatMessage(messages.groupBlockFromGroupHeading),
message: intl.formatMessage(messages.groupBlockFromGroupMessage, { name: (status.account as any).username }),
confirm: intl.formatMessage(messages.groupBlockConfirm),
onConfirm: () => {
blockGroupMember({ account_ids: [(status.account as any).id] }, {
onSuccess() {
toast.success(intl.formatMessage(messages.blocked, { name: account?.acct }));
},
});
},
}));
};
const _makeMenu = (publicStatus: boolean) => { const _makeMenu = (publicStatus: boolean) => {
const mutingConversation = status.muted; const mutingConversation = status.muted;
const ownAccount = status.account.id === me; const ownAccount = status.account.id === me;
@ -472,6 +526,15 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
} }
menu.push(null); 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({ menu.push({
text: intl.formatMessage(messages.mute, { name: username }), text: intl.formatMessage(messages.mute, { name: username }),
action: handleMuteClick, action: handleMuteClick,
@ -495,10 +558,24 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
const isGroupOwner = groupRelationship?.role === GroupRoles.OWNER; const isGroupOwner = groupRelationship?.role === GroupRoles.OWNER;
const isGroupAdmin = groupRelationship?.role === GroupRoles.ADMIN; const isGroupAdmin = groupRelationship?.role === GroupRoles.ADMIN;
const isStatusFromOwner = group.owner.id === account.id; const isStatusFromOwner = group.owner.id === account.id;
const canBanUser = match?.isExact && (isGroupOwner || isGroupAdmin) && !isStatusFromOwner && !ownAccount;
const canDeleteStatus = !ownAccount && (isGroupOwner || (isGroupAdmin && !isStatusFromOwner)); const canDeleteStatus = !ownAccount && (isGroupOwner || (isGroupAdmin && !isStatusFromOwner));
if (canDeleteStatus) { if (canBanUser || canDeleteStatus) {
menu.push(null); menu.push(null);
}
if (canBanUser) {
menu.push({
text: 'Ban from Group',
action: handleBlockFromGroup,
icon: require('@tabler/icons/ban.svg'),
destructive: true,
});
}
if (canDeleteStatus) {
menu.push({ menu.push({
text: intl.formatMessage(messages.groupModDelete), text: intl.formatMessage(messages.groupModDelete),
action: handleDeleteFromGroup, action: handleDeleteFromGroup,

View file

@ -4,6 +4,7 @@ enum Entities {
ACCOUNTS = 'Accounts', ACCOUNTS = 'Accounts',
GROUPS = 'Groups', GROUPS = 'Groups',
GROUP_MEMBERSHIPS = 'GroupMemberships', GROUP_MEMBERSHIPS = 'GroupMemberships',
GROUP_MUTES = 'GroupMutes',
GROUP_RELATIONSHIPS = 'GroupRelationships', GROUP_RELATIONSHIPS = 'GroupRelationships',
GROUP_TAGS = 'GroupTags', GROUP_TAGS = 'GroupTags',
PATRON_USERS = 'PatronUsers', PATRON_USERS = 'PatronUsers',

View file

@ -7,7 +7,7 @@ import ScrollableList from 'soapbox/components/scrollable-list';
import { Column, Spinner } from 'soapbox/components/ui'; import { Column, Spinner } from 'soapbox/components/ui';
const messages = defineMessages({ const messages = defineMessages({
heading: { id: 'column.blocks', defaultMessage: 'Blocked users' }, heading: { id: 'column.blocks', defaultMessage: 'Blocks' },
}); });
const Blocks: React.FC = () => { const Blocks: React.FC = () => {
@ -37,7 +37,8 @@ const Blocks: React.FC = () => {
onLoadMore={fetchNextPage} onLoadMore={fetchNextPage}
hasMore={hasNextPage} hasMore={hasNextPage}
emptyMessage={emptyMessage} emptyMessage={emptyMessage}
itemClassName='pb-4' emptyMessageCard={false}
itemClassName='pb-4 last:pb-0'
> >
{accounts.map((account) => ( {accounts.map((account) => (
<Account key={account.id} account={account} actionType='blocking' /> <Account key={account.id} account={account} actionType='blocking' />

View file

@ -59,10 +59,10 @@ describe('<GroupOptionsButton />', () => {
}); });
}); });
it('should render null', () => { it('should render one option for muting the group', () => {
render(<GroupOptionsButton group={group} />); render(<GroupOptionsButton group={group} />);
expect(screen.queryAllByTestId('dropdown-menu-button')).toHaveLength(0); expect(screen.queryAllByTestId('dropdown-menu-button')).toHaveLength(1);
}); });
}); });

View file

@ -53,7 +53,7 @@ const GroupMemberListItem = (props: IGroupMemberListItem) => {
const features = useFeatures(); const features = useFeatures();
const intl = useIntl(); const intl = useIntl();
const blockGroupMember = useBlockGroupMember(group, member); const blockGroupMember = useBlockGroupMember(group, member.account);
const promoteGroupMember = usePromoteGroupMember(group, member); const promoteGroupMember = usePromoteGroupMember(group, member);
const demoteGroupMember = useDemoteGroupMember(group, member); const demoteGroupMember = useDemoteGroupMember(group, member);

View file

@ -3,7 +3,7 @@ import { defineMessages, useIntl } from 'react-intl';
import { openModal } from 'soapbox/actions/modals'; import { openModal } from 'soapbox/actions/modals';
import { initReport, ReportableEntities } from 'soapbox/actions/reports'; 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 DropdownMenu, { Menu } from 'soapbox/components/dropdown-menu';
import { IconButton } from 'soapbox/components/ui'; import { IconButton } from 'soapbox/components/ui';
import { useAppDispatch, useOwnAccount } from 'soapbox/hooks'; import { useAppDispatch, useOwnAccount } from 'soapbox/hooks';
@ -14,10 +14,17 @@ import type { Account, Group } from 'soapbox/types/entities';
const messages = defineMessages({ const messages = defineMessages({
confirmationConfirm: { id: 'confirmations.leave_group.confirm', defaultMessage: 'Leave' }, 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?' }, 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' }, leave: { id: 'group.leave.label', defaultMessage: 'Leave' },
leaveSuccess: { id: 'group.leave.success', defaultMessage: 'Left the group' }, 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' }, report: { id: 'group.report.label', defaultMessage: 'Report' },
share: { id: 'group.share.label', defaultMessage: 'Share' }, share: { id: 'group.share.label', defaultMessage: 'Share' },
}); });
@ -30,11 +37,16 @@ const GroupOptionsButton = ({ group }: IGroupActionButton) => {
const { account } = useOwnAccount(); const { account } = useOwnAccount();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const intl = useIntl(); const intl = useIntl();
const muteGroup = useMuteGroup(group);
const unmuteGroup = useUnmuteGroup(group);
const leaveGroup = useLeaveGroup(group); const leaveGroup = useLeaveGroup(group);
const isMember = group.relationship?.role === GroupRoles.USER; const isMember = group.relationship?.role === GroupRoles.USER;
const isAdmin = group.relationship?.role === GroupRoles.ADMIN; const isAdmin = group.relationship?.role === GroupRoles.ADMIN;
const isInGroup = !!group.relationship?.member;
const isBlocked = group.relationship?.blocked_by; const isBlocked = group.relationship?.blocked_by;
const isMuting = group.relationship?.muting;
const handleShare = () => { const handleShare = () => {
navigator.share({ 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', { dispatch(openModal('CONFIRM', {
heading: intl.formatMessage(messages.confirmationHeading), heading: intl.formatMessage(messages.confirmationHeading),
message: intl.formatMessage(messages.confirmationMessage), message: intl.formatMessage(messages.confirmationMessage),
@ -62,14 +95,6 @@ const GroupOptionsButton = ({ group }: IGroupActionButton) => {
const canShare = 'share' in navigator; const canShare = 'share' in navigator;
const items = []; 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) { if (canShare) {
items.push({ items.push({
text: intl.formatMessage(messages.share), 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) { if (isAdmin) {
items.push(null);
items.push({ items.push({
text: intl.formatMessage(messages.leave), text: intl.formatMessage(messages.leave),
icon: require('@tabler/icons/logout.svg'), icon: require('@tabler/icons/logout.svg'),
action: onLeaveGroup, action: handleLeave,
}); });
} }
return items; return items;
}, [isMember, isAdmin]); }, [isMember, isAdmin, isInGroup, isMuting]);
if (isBlocked || menu.length === 0) { if (isBlocked || menu.length === 0) {
return null; return null;

View file

@ -8,12 +8,12 @@ import GroupActionButton from 'soapbox/features/group/components/group-action-bu
import { Group as GroupEntity } from 'soapbox/types/entities'; import { Group as GroupEntity } from 'soapbox/types/entities';
import { shortNumberFormat } from 'soapbox/utils/numbers'; import { shortNumberFormat } from 'soapbox/utils/numbers';
interface IGroup { interface IGroupListItem {
group: GroupEntity group: GroupEntity
withJoinAction?: boolean withJoinAction?: boolean
} }
const GroupListItem = (props: IGroup) => { const GroupListItem = (props: IGroupListItem) => {
const { group, withJoinAction = true } = props; const { group, withJoinAction = true } = props;
return ( return (

View file

@ -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 (
<HStack alignItems='center' justifyContent='between'>
<HStack alignItems='center' space={3}>
<GroupAvatar
group={group}
size={42}
/>
<Text
weight='semibold'
size='sm'
dangerouslySetInnerHTML={{ __html: group.display_name_html }}
truncate
/>
</HStack>
<Button theme='primary' type='button' onClick={handleUnmute} size='sm'>
<FormattedMessage id='group.unmute.label' defaultMessage='Unmute' />
</Button>
</HStack>
);
};
export default GroupListItem;

View file

@ -1,48 +1,105 @@
import React from 'react'; import React, { useState } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl'; import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { useMutes } from 'soapbox/api/hooks'; import { useMutes, useGroupMutes } from 'soapbox/api/hooks';
import Account from 'soapbox/components/account';
import ScrollableList from 'soapbox/components/scrollable-list'; 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({ 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 Mutes: React.FC = () => {
const intl = useIntl(); const intl = useIntl();
const features = useFeatures();
const { const {
accounts, accounts,
hasNextPage, hasNextPage: hasNextAccountsPage,
fetchNextPage, fetchNextPage: fetchNextAccounts,
isLoading, isLoading: isLoadingAccounts,
} = useMutes(); } = useMutes();
if (isLoading) { const {
return ( mutes: groupMutes,
<Column> isLoading: isLoadingGroups,
<Spinner /> hasNextPage: hasNextGroupsPage,
</Column> fetchNextPage: fetchNextGroups,
); fetchEntities: fetchMutedGroups,
} } = useGroupMutes();
const emptyMessage = <FormattedMessage id='empty_column.mutes' defaultMessage="You haven't muted any users yet." />; const [activeItem, setActiveItem] = useState<TabItems>(TabItems.ACCOUNTS);
const isAccountsTabSelected = activeItem === TabItems.ACCOUNTS;
const scrollableListProps = {
itemClassName: 'pb-4 last:pb-0',
scrollKey: 'mutes',
emptyMessageCard: false,
};
return ( return (
<Column label={intl.formatMessage(messages.heading)}> <Column label={intl.formatMessage(messages.heading)}>
<ScrollableList <Stack space={4}>
scrollKey='mutes' {features.groupsMuting && (
onLoadMore={fetchNextPage} <Tabs
hasMore={hasNextPage} items={[
emptyMessage={emptyMessage} {
itemClassName='pb-4' text: 'Users',
> action: () => setActiveItem(TabItems.ACCOUNTS),
{accounts.map((account) => ( name: TabItems.ACCOUNTS,
<Account key={account.id} account={account} actionType='muting' /> },
))} {
</ScrollableList> text: 'Groups',
action: () => setActiveItem(TabItems.GROUPS),
name: TabItems.GROUPS,
},
]}
activeItem={activeItem}
/>
)}
{isAccountsTabSelected ? (
<ScrollableList
{...scrollableListProps}
isLoading={isLoadingAccounts}
onLoadMore={fetchNextAccounts}
hasMore={hasNextAccountsPage}
emptyMessage={
<FormattedMessage id='empty_column.mutes' defaultMessage="You haven't muted any users yet." />
}
>
{accounts.map((accounts) =>
<AccountContainer key={accounts.id} id={accounts.id} actionType='muting' />,
)}
</ScrollableList>
) : (
<ScrollableList
{...scrollableListProps}
isLoading={isLoadingGroups}
onLoadMore={fetchNextGroups}
hasMore={hasNextGroupsPage}
emptyMessage={
<FormattedMessage id='mutes.empty.groups' defaultMessage="You haven't muted any groups yet." />
}
>
{groupMutes.map((group) =>(
<GroupListItem
group={group}
onUnmute={fetchMutedGroups}
/>
))}
</ScrollableList>
)}
</Stack>
</Column> </Column>
); );
}; };

View file

@ -12,24 +12,27 @@ import Preferences from '../preferences';
import MessagesSettings from './components/messages-settings'; import MessagesSettings from './components/messages-settings';
const messages = defineMessages({ const messages = defineMessages({
settings: { id: 'settings.settings', defaultMessage: 'Settings' }, accountAliases: { id: 'navigation_bar.account_aliases', defaultMessage: 'Account aliases' },
profile: { id: 'settings.profile', defaultMessage: 'Profile' }, accountMigration: { id: 'settings.account_migration', defaultMessage: 'Move Account' },
security: { id: 'settings.security', defaultMessage: 'Security' }, backups: { id: 'column.backups', defaultMessage: 'Backups' },
preferences: { id: 'settings.preferences', defaultMessage: 'Preferences' }, blocks: { id: 'settings.blocks', defaultMessage: 'Blocks' },
editProfile: { id: 'settings.edit_profile', defaultMessage: 'Edit Profile' },
changeEmail: { id: 'settings.change_email', defaultMessage: 'Change Email' }, changeEmail: { id: 'settings.change_email', defaultMessage: 'Change Email' },
changePassword: { id: 'settings.change_password', defaultMessage: 'Change Password' }, changePassword: { id: 'settings.change_password', defaultMessage: 'Change Password' },
configureMfa: { id: 'settings.configure_mfa', defaultMessage: 'Configure MFA' }, configureMfa: { id: 'settings.configure_mfa', defaultMessage: 'Configure MFA' },
sessions: { id: 'settings.sessions', defaultMessage: 'Active sessions' },
deleteAccount: { id: 'settings.delete_account', defaultMessage: 'Delete Account' }, deleteAccount: { id: 'settings.delete_account', defaultMessage: 'Delete Account' },
accountMigration: { id: 'settings.account_migration', defaultMessage: 'Move Account' }, editProfile: { id: 'settings.edit_profile', defaultMessage: 'Edit Profile' },
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' },
exportData: { id: 'column.export_data', defaultMessage: 'Export data' }, 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. */ /** User settings page. */
@ -53,6 +56,8 @@ const Settings = () => {
const navigateToBackups = () => history.push('/settings/backups'); const navigateToBackups = () => history.push('/settings/backups');
const navigateToImportData = () => history.push('/settings/import'); const navigateToImportData = () => history.push('/settings/import');
const navigateToExportData = () => history.push('/settings/export'); const navigateToExportData = () => history.push('/settings/export');
const navigateToMutes = () => history.push('/mutes');
const navigateToBlocks = () => history.push('/blocks');
const isMfaEnabled = mfa.getIn(['settings', 'totp']); const isMfaEnabled = mfa.getIn(['settings', 'totp']);
@ -79,6 +84,17 @@ const Settings = () => {
</List> </List>
</CardBody> </CardBody>
<CardHeader>
<CardTitle title={intl.formatMessage(messages.privacy)} />
</CardHeader>
<CardBody>
<List>
<ListItem label={intl.formatMessage(messages.mutes)} onClick={navigateToMutes} />
<ListItem label={intl.formatMessage(messages.blocks)} onClick={navigateToBlocks} />
</List>
</CardBody>
{(features.security || features.sessions) && ( {(features.security || features.sessions) && (
<> <>
<CardHeader> <CardHeader>

View file

@ -308,7 +308,7 @@
"column.app_create": "Create app", "column.app_create": "Create app",
"column.backups": "Backups", "column.backups": "Backups",
"column.birthdays": "Birthdays", "column.birthdays": "Birthdays",
"column.blocks": "Blocked users", "column.blocks": "Blocks",
"column.bookmarks": "Bookmarks", "column.bookmarks": "Bookmarks",
"column.chats": "Chats", "column.chats": "Chats",
"column.community": "Local timeline", "column.community": "Local timeline",
@ -367,7 +367,7 @@
"column.mfa_disable_button": "Disable", "column.mfa_disable_button": "Disable",
"column.mfa_setup": "Proceed to Setup", "column.mfa_setup": "Proceed to Setup",
"column.migration": "Account migration", "column.migration": "Account migration",
"column.mutes": "Muted users", "column.mutes": "Mutes",
"column.notifications": "Notifications", "column.notifications": "Notifications",
"column.pins": "Pinned posts", "column.pins": "Pinned posts",
"column.preferences": "Preferences", "column.preferences": "Preferences",
@ -510,6 +510,9 @@
"confirmations.mute.confirm": "Mute", "confirmations.mute.confirm": "Mute",
"confirmations.mute.heading": "Mute @{name}", "confirmations.mute.heading": "Mute @{name}",
"confirmations.mute.message": "Are you sure you want to 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.confirm": "Delete & redraft",
"confirmations.redraft.heading": "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.", "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.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.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.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.action": "View Group",
"group.popover.summary": "You must be a member of the group in order to reply to this status.", "group.popover.summary": "You must be a member of the group in order to reply to this status.",
"group.popover.title": "Membership required", "group.popover.title": "Membership required",
@ -826,6 +832,9 @@
"group.tags.unpin": "Unpin topic", "group.tags.unpin": "Unpin topic",
"group.tags.unpin.success": "Unpinned!", "group.tags.unpin.success": "Unpinned!",
"group.tags.visible.success": "Topic marked as visible", "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.update.success": "Group successfully saved",
"group.upload_banner": "Upload photo", "group.upload_banner": "Upload photo",
"groups.discover.popular.empty": "Unable to fetch popular groups at this time. Please check back later.", "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.auto_expire": "Automatically expire mute?",
"mute_modal.duration": "Duration", "mute_modal.duration": "Duration",
"mute_modal.hide_notifications": "Hide notifications from this user?", "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.action": "Log in",
"navbar.login.email.placeholder": "E-mail address", "navbar.login.email.placeholder": "E-mail address",
"navbar.login.forgot_password": "Forgot password?", "navbar.login.forgot_password": "Forgot password?",
@ -1351,14 +1361,17 @@
"security.update_password.fail": "Update password failed.", "security.update_password.fail": "Update password failed.",
"security.update_password.success": "Password successfully updated.", "security.update_password.success": "Password successfully updated.",
"settings.account_migration": "Move Account", "settings.account_migration": "Move Account",
"settings.blocks": "Blocks",
"settings.change_email": "Change Email", "settings.change_email": "Change Email",
"settings.change_password": "Change Password", "settings.change_password": "Change Password",
"settings.configure_mfa": "Configure MFA", "settings.configure_mfa": "Configure MFA",
"settings.delete_account": "Delete Account", "settings.delete_account": "Delete Account",
"settings.edit_profile": "Edit Profile", "settings.edit_profile": "Edit Profile",
"settings.messages.label": "Allow users to start a new chat with you", "settings.messages.label": "Allow users to start a new chat with you",
"settings.mutes": "Mutes",
"settings.other": "Other Options", "settings.other": "Other Options",
"settings.preferences": "Preferences", "settings.preferences": "Preferences",
"settings.privacy": "Privacy",
"settings.profile": "Profile", "settings.profile": "Profile",
"settings.save.success": "Your preferences have been saved!", "settings.save.success": "Your preferences have been saved!",
"settings.security": "Security", "settings.security": "Security",

View file

@ -16,6 +16,7 @@ export const GroupRelationshipRecord = ImmutableRecord({
member: false, member: false,
notifying: null, notifying: null,
requested: false, requested: false,
muting: false,
role: 'user' as GroupRoles, role: 'user' as GroupRoles,
pending_requests: false, pending_requests: false,
}); });

View file

@ -65,10 +65,6 @@ import {
DISLIKES_FETCH_SUCCESS, DISLIKES_FETCH_SUCCESS,
REACTIONS_FETCH_SUCCESS, REACTIONS_FETCH_SUCCESS,
} from 'soapbox/actions/interactions'; } from 'soapbox/actions/interactions';
import {
MUTES_FETCH_SUCCESS,
MUTES_EXPAND_SUCCESS,
} from 'soapbox/actions/mutes';
import { import {
NOTIFICATIONS_UPDATE, NOTIFICATIONS_UPDATE,
} from 'soapbox/actions/notifications'; } from 'soapbox/actions/notifications';
@ -203,10 +199,6 @@ export default function userLists(state = ReducerRecord(), action: AnyAction) {
return normalizeList(state, ['blocks'], action.accounts, action.next); return normalizeList(state, ['blocks'], action.accounts, action.next);
case BLOCKS_EXPAND_SUCCESS: case BLOCKS_EXPAND_SUCCESS:
return appendToList(state, ['blocks'], action.accounts, action.next); 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: case DIRECTORY_FETCH_SUCCESS:
return normalizeList(state, ['directory'], action.accounts, action.next); return normalizeList(state, ['directory'], action.accounts, action.next);
case DIRECTORY_EXPAND_SUCCESS: case DIRECTORY_EXPAND_SUCCESS:

View file

@ -3,13 +3,14 @@ import z from 'zod';
import { GroupRoles } from './group-member'; import { GroupRoles } from './group-member';
const groupRelationshipSchema = z.object({ const groupRelationshipSchema = z.object({
blocked_by: z.boolean().catch(false),
id: z.string(), id: z.string(),
member: z.boolean().catch(false), member: z.boolean().catch(false),
requested: z.boolean().catch(false), muting: z.boolean().nullable().catch(false),
role: z.nativeEnum(GroupRoles).catch(GroupRoles.USER),
blocked_by: z.boolean().catch(false),
notifying: z.boolean().nullable().catch(null), notifying: z.boolean().nullable().catch(null),
pending_requests: z.boolean().catch(false), pending_requests: z.boolean().catch(false),
requested: z.boolean().catch(false),
role: z.nativeEnum(GroupRoles).catch(GroupRoles.USER),
}); });
type GroupRelationship = z.infer<typeof groupRelationshipSchema>; type GroupRelationship = z.infer<typeof groupRelationshipSchema>;

View file

@ -567,6 +567,11 @@ const getInstanceFeatures = (instance: Instance) => {
*/ */
groupsKick: v.software !== TRUTHSOCIAL, groupsKick: v.software !== TRUTHSOCIAL,
/**
* Can mute a Group.
*/
groupsMuting: v.software === TRUTHSOCIAL,
/** /**
* Can query pending Group requests. * Can query pending Group requests.
*/ */