Merge branch 'block-group-members' into 'develop'
Use Entity Hooks for Blocking Group members See merge request soapbox-pub/soapbox!2353
This commit is contained in:
commit
709edaefad
21 changed files with 282 additions and 83 deletions
|
@ -14,10 +14,11 @@ import RelativeTimestamp from './relative-timestamp';
|
||||||
import { Avatar, Emoji, HStack, Icon, IconButton, Stack, Text } from './ui';
|
import { Avatar, Emoji, HStack, Icon, IconButton, Stack, Text } from './ui';
|
||||||
|
|
||||||
import type { StatusApprovalStatus } from 'soapbox/normalizers/status';
|
import type { StatusApprovalStatus } from 'soapbox/normalizers/status';
|
||||||
|
import type { Account as AccountSchema } from 'soapbox/schemas';
|
||||||
import type { Account as AccountEntity } from 'soapbox/types/entities';
|
import type { Account as AccountEntity } from 'soapbox/types/entities';
|
||||||
|
|
||||||
interface IInstanceFavicon {
|
interface IInstanceFavicon {
|
||||||
account: AccountEntity
|
account: AccountEntity | AccountSchema
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -67,7 +68,7 @@ const ProfilePopper: React.FC<IProfilePopper> = ({ condition, wrapper, children
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface IAccount {
|
export interface IAccount {
|
||||||
account: AccountEntity
|
account: AccountEntity | AccountSchema
|
||||||
action?: React.ReactElement
|
action?: React.ReactElement
|
||||||
actionAlignment?: 'center' | 'top'
|
actionAlignment?: 'center' | 'top'
|
||||||
actionIcon?: string
|
actionIcon?: string
|
||||||
|
|
|
@ -73,7 +73,7 @@ const DropdownMenuItem = ({ index, item, onClick }: IDropdownMenuItem) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li className='truncate focus-within:ring-2 focus-within:ring-primary-500'>
|
<li className='truncate focus-visible:ring-2 focus-visible:ring-primary-500'>
|
||||||
<a
|
<a
|
||||||
href={item.href || item.to || '#'}
|
href={item.href || item.to || '#'}
|
||||||
role='button'
|
role='button'
|
||||||
|
|
|
@ -271,6 +271,10 @@ const DropdownMenu = (props: IDropdownMenu) => {
|
||||||
};
|
};
|
||||||
}, [refs.floating.current]);
|
}, [refs.floating.current]);
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{children ? (
|
{children ? (
|
||||||
|
|
|
@ -8,7 +8,7 @@ const themes = {
|
||||||
tertiary:
|
tertiary:
|
||||||
'bg-transparent border-gray-400 dark:border-gray-800 hover:border-primary-300 dark:hover:border-primary-700 focus:border-primary-500 text-gray-900 dark:text-gray-100 focus:ring-primary-500',
|
'bg-transparent border-gray-400 dark:border-gray-800 hover:border-primary-300 dark:hover:border-primary-700 focus:border-primary-500 text-gray-900 dark:text-gray-100 focus:ring-primary-500',
|
||||||
accent: 'border-transparent bg-secondary-500 hover:bg-secondary-400 focus:bg-secondary-500 text-gray-100 focus:ring-secondary-300',
|
accent: 'border-transparent bg-secondary-500 hover:bg-secondary-400 focus:bg-secondary-500 text-gray-100 focus:ring-secondary-300',
|
||||||
danger: 'border-transparent bg-danger-100 dark:bg-danger-900 text-danger-600 dark:text-danger-200 hover:bg-danger-600 hover:text-gray-100 dark:hover:text-gray-100 dark:hover:bg-danger-500 focus:bg-danger-800 focus:text-gray-200 dark:focus:bg-danger-600 dark:focus:text-gray-100',
|
danger: 'border-transparent bg-danger-100 dark:bg-danger-900 text-danger-600 dark:text-danger-200 hover:bg-danger-600 hover:text-gray-100 dark:hover:text-gray-100 dark:hover:bg-danger-500 focus:ring-danger-500',
|
||||||
transparent: 'border-transparent text-gray-800 backdrop-blur-sm bg-white/75 hover:bg-white/80',
|
transparent: 'border-transparent text-gray-800 backdrop-blur-sm bg-white/75 hover:bg-white/80',
|
||||||
outline: 'border-gray-100 border-2 bg-transparent text-gray-100 hover:bg-white/10',
|
outline: 'border-gray-100 border-2 bg-transparent text-gray-100 hover:bg-white/10',
|
||||||
muted: 'border border-solid bg-transparent border-gray-400 dark:border-gray-800 hover:border-primary-300 dark:hover:border-primary-700 focus:border-primary-500 text-gray-900 dark:text-gray-100 focus:ring-primary-500',
|
muted: 'border border-solid bg-transparent border-gray-400 dark:border-gray-800 hover:border-primary-300 dark:hover:border-primary-700 focus:border-primary-500 text-gray-900 dark:text-gray-100 focus:ring-primary-500',
|
||||||
|
|
|
@ -131,7 +131,12 @@ function useEntities<TEntity extends Entity>(
|
||||||
const selectCache = (state: RootState, path: EntityPath) => state.entities[path[0]];
|
const selectCache = (state: RootState, path: EntityPath) => state.entities[path[0]];
|
||||||
|
|
||||||
/** Get list at path from Redux. */
|
/** Get list at path from Redux. */
|
||||||
const selectList = (state: RootState, path: EntityPath) => selectCache(state, path)?.lists[path[1]];
|
const selectList = (state: RootState, path: EntityPath) => {
|
||||||
|
const [, ...listKeys] = path;
|
||||||
|
const listKey = listKeys.join(':');
|
||||||
|
|
||||||
|
return selectCache(state, path)?.lists[listKey];
|
||||||
|
};
|
||||||
|
|
||||||
/** Select a particular item from a list state. */
|
/** Select a particular item from a list state. */
|
||||||
function selectListState<K extends keyof EntityListState>(state: RootState, path: EntityPath, key: K) {
|
function selectListState<K extends keyof EntityListState>(state: RootState, path: EntityPath, key: K) {
|
||||||
|
|
|
@ -67,7 +67,7 @@ function useEntityActions<TEntity extends Entity = Entity, P = any>(
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
createEntity: endpoints.post ? createEntity : undefined,
|
createEntity: createEntity,
|
||||||
deleteEntity: endpoints.delete ? deleteEntity : undefined,
|
deleteEntity: endpoints.delete ? deleteEntity : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -28,6 +28,7 @@ const GroupActionButton = ({ group }: IGroupActionButton) => {
|
||||||
const isRequested = group.relationship?.requested;
|
const isRequested = group.relationship?.requested;
|
||||||
const isNonMember = !group.relationship?.member && !isRequested;
|
const isNonMember = !group.relationship?.member && !isRequested;
|
||||||
const isAdmin = group.relationship?.role === 'admin';
|
const isAdmin = group.relationship?.role === 'admin';
|
||||||
|
const isBlocked = group.relationship?.blocked_by;
|
||||||
|
|
||||||
const onJoinGroup = () => joinGroup.mutate(group);
|
const onJoinGroup = () => joinGroup.mutate(group);
|
||||||
|
|
||||||
|
@ -41,6 +42,10 @@ const GroupActionButton = ({ group }: IGroupActionButton) => {
|
||||||
|
|
||||||
const onCancelRequest = () => cancelRequest.mutate(group);
|
const onCancelRequest = () => cancelRequest.mutate(group);
|
||||||
|
|
||||||
|
if (isBlocked) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
if (isNonMember) {
|
if (isNonMember) {
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
|
|
|
@ -1,14 +1,16 @@
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
|
|
||||||
import { groupBlock, groupDemoteAccount, groupKick, groupPromoteAccount } from 'soapbox/actions/groups';
|
import { groupDemoteAccount, groupKick, groupPromoteAccount } from 'soapbox/actions/groups';
|
||||||
import { openModal } from 'soapbox/actions/modals';
|
import { openModal } from 'soapbox/actions/modals';
|
||||||
import Account from 'soapbox/components/account';
|
import Account from 'soapbox/components/account';
|
||||||
import { HStack, IconButton, Menu, MenuButton, MenuDivider, MenuItem, MenuLink, MenuList } from 'soapbox/components/ui';
|
import DropdownMenu from 'soapbox/components/dropdown-menu/dropdown-menu';
|
||||||
import SvgIcon from 'soapbox/components/ui/icon/svg-icon';
|
import { HStack } from 'soapbox/components/ui';
|
||||||
import { useAccount, useAppDispatch } from 'soapbox/hooks';
|
import { deleteEntities } from 'soapbox/entity-store/actions';
|
||||||
|
import { Entities } from 'soapbox/entity-store/entities';
|
||||||
|
import { useAccount, useAppDispatch, useFeatures } from 'soapbox/hooks';
|
||||||
|
import { useBlockGroupMember } from 'soapbox/hooks/api/groups/useBlockGroupMember';
|
||||||
import { BaseGroupRoles, useGroupRoles } from 'soapbox/hooks/useGroupRoles';
|
import { BaseGroupRoles, useGroupRoles } from 'soapbox/hooks/useGroupRoles';
|
||||||
import toast from 'soapbox/toast';
|
import toast from 'soapbox/toast';
|
||||||
|
|
||||||
|
@ -17,10 +19,11 @@ import type { Account as AccountEntity, Group, GroupMember } from 'soapbox/types
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
blockConfirm: { id: 'confirmations.block_from_group.confirm', defaultMessage: 'Block' },
|
blockConfirm: { id: 'confirmations.block_from_group.confirm', defaultMessage: 'Block' },
|
||||||
blockFromGroupMessage: { id: 'confirmations.block_from_group.message', defaultMessage: 'Are you sure you want to block @{name} from interacting with this group?' },
|
blockFromGroupHeading: { id: 'confirmations.block_from_group.heading', defaultMessage: 'Ban From Group' },
|
||||||
blocked: { id: 'group.group_mod_block.success', defaultMessage: 'Blocked @{name} from group' },
|
blockFromGroupMessage: { id: 'confirmations.block_from_group.message', defaultMessage: 'Are you sure you want to ban @{name} from the group?' },
|
||||||
|
blocked: { id: 'group.group_mod_block.success', defaultMessage: 'You have successfully blocked @{name} from the group' },
|
||||||
demotedToUser: { id: 'group.group_mod_demote.success', defaultMessage: 'Demoted @{name} to group user' },
|
demotedToUser: { id: 'group.group_mod_demote.success', defaultMessage: 'Demoted @{name} to group user' },
|
||||||
groupModBlock: { id: 'group.group_mod_block', defaultMessage: 'Block @{name} from group' },
|
groupModBlock: { id: 'group.group_mod_block', defaultMessage: 'Ban from group' },
|
||||||
groupModDemote: { id: 'group.group_mod_demote', defaultMessage: 'Demote @{name}' },
|
groupModDemote: { id: 'group.group_mod_demote', defaultMessage: 'Demote @{name}' },
|
||||||
groupModKick: { id: 'group.group_mod_kick', defaultMessage: 'Kick @{name} from group' },
|
groupModKick: { id: 'group.group_mod_kick', defaultMessage: 'Kick @{name} from group' },
|
||||||
groupModPromoteAdmin: { id: 'group.group_mod_promote_admin', defaultMessage: 'Promote @{name} to group administrator' },
|
groupModPromoteAdmin: { id: 'group.group_mod_promote_admin', defaultMessage: 'Promote @{name} to group administrator' },
|
||||||
|
@ -43,9 +46,11 @@ const GroupMemberListItem = (props: IGroupMemberListItem) => {
|
||||||
const { member, group } = props;
|
const { member, group } = props;
|
||||||
|
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
const features = useFeatures();
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
|
||||||
const { normalizeRole } = useGroupRoles();
|
const { normalizeRole } = useGroupRoles();
|
||||||
|
const blockGroupMember = useBlockGroupMember(group, member);
|
||||||
|
|
||||||
const account = useAccount(member.account.id) as AccountEntity;
|
const account = useAccount(member.account.id) as AccountEntity;
|
||||||
|
|
||||||
|
@ -70,11 +75,13 @@ const GroupMemberListItem = (props: IGroupMemberListItem) => {
|
||||||
|
|
||||||
const handleBlockFromGroup = () => {
|
const handleBlockFromGroup = () => {
|
||||||
dispatch(openModal('CONFIRM', {
|
dispatch(openModal('CONFIRM', {
|
||||||
|
heading: intl.formatMessage(messages.blockFromGroupHeading),
|
||||||
message: intl.formatMessage(messages.blockFromGroupMessage, { name: account.username }),
|
message: intl.formatMessage(messages.blockFromGroupMessage, { name: account.username }),
|
||||||
confirm: intl.formatMessage(messages.blockConfirm),
|
confirm: intl.formatMessage(messages.blockConfirm),
|
||||||
onConfirm: () => dispatch(groupBlock(group.id, account.id)).then(() =>
|
onConfirm: () => blockGroupMember({ account_ids: [member.account.id] }).then(() => {
|
||||||
toast.success(intl.formatMessage(messages.blocked, { name: account.acct })),
|
dispatch(deleteEntities([member.id], Entities.GROUP_MEMBERSHIPS));
|
||||||
),
|
toast.success(intl.formatMessage(messages.blocked, { name: account.acct }));
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -118,15 +125,19 @@ const GroupMemberListItem = (props: IGroupMemberListItem) => {
|
||||||
(isMemberModerator || isMemberUser) &&
|
(isMemberModerator || isMemberUser) &&
|
||||||
member.role !== group.relationship.role
|
member.role !== group.relationship.role
|
||||||
) {
|
) {
|
||||||
items.push({
|
if (features.groupsKick) {
|
||||||
text: intl.formatMessage(messages.groupModKick, { name: account.username }),
|
items.push({
|
||||||
icon: require('@tabler/icons/user-minus.svg'),
|
text: intl.formatMessage(messages.groupModKick, { name: account.username }),
|
||||||
action: handleKickFromGroup,
|
icon: require('@tabler/icons/user-minus.svg'),
|
||||||
});
|
action: handleKickFromGroup,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
items.push({
|
items.push({
|
||||||
text: intl.formatMessage(messages.groupModBlock, { name: account.username }),
|
text: intl.formatMessage(messages.groupModBlock, { name: account.username }),
|
||||||
icon: require('@tabler/icons/ban.svg'),
|
icon: require('@tabler/icons/ban.svg'),
|
||||||
action: handleBlockFromGroup,
|
action: handleBlockFromGroup,
|
||||||
|
destructive: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -176,40 +187,7 @@ const GroupMemberListItem = (props: IGroupMemberListItem) => {
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{menu.length > 0 && (
|
<DropdownMenu items={menu} />
|
||||||
<Menu>
|
|
||||||
<MenuButton
|
|
||||||
as={IconButton}
|
|
||||||
src={require('@tabler/icons/dots.svg')}
|
|
||||||
className='px-2'
|
|
||||||
iconClassName='h-4 w-4'
|
|
||||||
children={null}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<MenuList className='w-56'>
|
|
||||||
{menu.map((menuItem, idx) => {
|
|
||||||
if (typeof menuItem?.text === 'undefined') {
|
|
||||||
return <MenuDivider key={idx} />;
|
|
||||||
} else {
|
|
||||||
const Comp = (menuItem.action ? MenuItem : MenuLink) as any;
|
|
||||||
const itemProps = menuItem.action ? { onSelect: menuItem.action } : { to: menuItem.to, as: Link, target: menuItem.target || '_self' };
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Comp key={idx} {...itemProps} className='group'>
|
|
||||||
<HStack space={2} alignItems='center'>
|
|
||||||
{menuItem.icon && (
|
|
||||||
<SvgIcon src={menuItem.icon} className='h-4 w-4 flex-none text-gray-700 group-hover:text-gray-800' />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className='truncate'>{menuItem.text}</div>
|
|
||||||
</HStack>
|
|
||||||
</Comp>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
})}
|
|
||||||
</MenuList>
|
|
||||||
</Menu>
|
|
||||||
)}
|
|
||||||
</HStack>
|
</HStack>
|
||||||
</HStack>
|
</HStack>
|
||||||
);
|
);
|
||||||
|
|
|
@ -50,7 +50,7 @@ const GroupMembers: React.FC<IGroupMembers> = (props) => {
|
||||||
<GroupMemberListItem
|
<GroupMemberListItem
|
||||||
group={group as Group}
|
group={group as Group}
|
||||||
member={member}
|
member={member}
|
||||||
key={member?.account}
|
key={member.account.id}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ScrollableList>
|
</ScrollableList>
|
||||||
|
|
|
@ -15,6 +15,7 @@ import { openModal } from 'soapbox/actions/modals';
|
||||||
import { Button, HStack } from 'soapbox/components/ui';
|
import { Button, HStack } from 'soapbox/components/ui';
|
||||||
import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks';
|
import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks';
|
||||||
|
|
||||||
|
import type { Account } from 'soapbox/schemas';
|
||||||
import type { Account as AccountEntity } from 'soapbox/types/entities';
|
import type { Account as AccountEntity } from 'soapbox/types/entities';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
|
@ -35,7 +36,7 @@ const messages = defineMessages({
|
||||||
|
|
||||||
interface IActionButton {
|
interface IActionButton {
|
||||||
/** Target account for the action. */
|
/** Target account for the action. */
|
||||||
account: AccountEntity
|
account: AccountEntity | Account
|
||||||
/** Type of action to prioritize, eg on Blocks and Mutes pages. */
|
/** Type of action to prioritize, eg on Blocks and Mutes pages. */
|
||||||
actionType?: 'muting' | 'blocking' | 'follow_request'
|
actionType?: 'muting' | 'blocking' | 'follow_request'
|
||||||
/** Displays shorter text on the "Awaiting approval" button. */
|
/** Displays shorter text on the "Awaiting approval" button. */
|
||||||
|
|
15
app/soapbox/hooks/api/groups/useBlockGroupMember.ts
Normal file
15
app/soapbox/hooks/api/groups/useBlockGroupMember.ts
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
import { Entities } from 'soapbox/entity-store/entities';
|
||||||
|
import { useEntityActions } from 'soapbox/entity-store/hooks';
|
||||||
|
|
||||||
|
import type { Group, GroupMember } from 'soapbox/schemas';
|
||||||
|
|
||||||
|
function useBlockGroupMember(group: Group, groupMember: GroupMember) {
|
||||||
|
const { createEntity } = useEntityActions(
|
||||||
|
[Entities.GROUP_MEMBERSHIPS, groupMember.id],
|
||||||
|
{ post: `/api/v1/groups/${group.id}/blocks` },
|
||||||
|
);
|
||||||
|
|
||||||
|
return createEntity;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { useBlockGroupMember };
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { Entities } from 'soapbox/entity-store/entities';
|
import { Entities } from 'soapbox/entity-store/entities';
|
||||||
import { useEntities, useEntity } from 'soapbox/entity-store/hooks';
|
import { useEntities, useEntity } from 'soapbox/entity-store/hooks';
|
||||||
import { groupSchema, Group } from 'soapbox/schemas/group';
|
import { groupSchema, Group } from 'soapbox/schemas/group';
|
||||||
|
@ -40,7 +42,7 @@ function useGroupRelationship(groupId: string) {
|
||||||
return useEntity<GroupRelationship>(
|
return useEntity<GroupRelationship>(
|
||||||
[Entities.GROUP_RELATIONSHIPS, groupId],
|
[Entities.GROUP_RELATIONSHIPS, groupId],
|
||||||
`/api/v1/groups/relationships?id[]=${groupId}`,
|
`/api/v1/groups/relationships?id[]=${groupId}`,
|
||||||
{ schema: groupRelationshipSchema },
|
{ schema: z.array(groupRelationshipSchema).transform(arr => arr[0]) },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -48,7 +50,7 @@ function useGroupRelationships(groupIds: string[]) {
|
||||||
const q = groupIds.map(id => `id[]=${id}`).join('&');
|
const q = groupIds.map(id => `id[]=${id}`).join('&');
|
||||||
const endpoint = groupIds.length ? `/api/v1/groups/relationships?${q}` : undefined;
|
const endpoint = groupIds.length ? `/api/v1/groups/relationships?${q}` : undefined;
|
||||||
const { entities, ...result } = useEntities<GroupRelationship>(
|
const { entities, ...result } = useEntities<GroupRelationship>(
|
||||||
[Entities.GROUP_RELATIONSHIPS, q],
|
[Entities.GROUP_RELATIONSHIPS, ...groupIds],
|
||||||
endpoint,
|
endpoint,
|
||||||
{ schema: groupRelationshipSchema },
|
{ schema: groupRelationshipSchema },
|
||||||
);
|
);
|
||||||
|
|
|
@ -472,7 +472,7 @@
|
||||||
"confirmations.block.message": "Are you sure you want to block {name}?",
|
"confirmations.block.message": "Are you sure you want to block {name}?",
|
||||||
"confirmations.block_from_group.confirm": "Block",
|
"confirmations.block_from_group.confirm": "Block",
|
||||||
"confirmations.block_from_group.heading": "Block group member",
|
"confirmations.block_from_group.heading": "Block group member",
|
||||||
"confirmations.block_from_group.message": "Are you sure you want to block @{name} from interacting with this group?",
|
"confirmations.block_from_group.message": "Are you sure you want to ban @{name} from the group?",
|
||||||
"confirmations.cancel.confirm": "Discard",
|
"confirmations.cancel.confirm": "Discard",
|
||||||
"confirmations.cancel.heading": "Discard post",
|
"confirmations.cancel.heading": "Discard post",
|
||||||
"confirmations.cancel.message": "Are you sure you want to cancel creating this post?",
|
"confirmations.cancel.message": "Are you sure you want to cancel creating this post?",
|
||||||
|
@ -770,8 +770,8 @@
|
||||||
"group.cancel_request": "Cancel Request",
|
"group.cancel_request": "Cancel Request",
|
||||||
"group.group_mod_authorize": "Accept",
|
"group.group_mod_authorize": "Accept",
|
||||||
"group.group_mod_authorize.success": "Accepted @{name} to group",
|
"group.group_mod_authorize.success": "Accepted @{name} to group",
|
||||||
"group.group_mod_block": "Block @{name} from group",
|
"group.group_mod_block": "Ban from group",
|
||||||
"group.group_mod_block.success": "Blocked @{name} from group",
|
"group.group_mod_block.success": "You have successfully blocked @{name} from the group",
|
||||||
"group.group_mod_demote": "Demote @{name}",
|
"group.group_mod_demote": "Demote @{name}",
|
||||||
"group.group_mod_demote.success": "Demoted @{name} to group user",
|
"group.group_mod_demote.success": "Demoted @{name} to group user",
|
||||||
"group.group_mod_kick": "Kick @{name} from group",
|
"group.group_mod_kick": "Kick @{name} from group",
|
||||||
|
|
|
@ -12,6 +12,7 @@ import {
|
||||||
SignUpPanel,
|
SignUpPanel,
|
||||||
} from 'soapbox/features/ui/util/async-components';
|
} from 'soapbox/features/ui/util/async-components';
|
||||||
import { useGroup, useOwnAccount } from 'soapbox/hooks';
|
import { useGroup, useOwnAccount } from 'soapbox/hooks';
|
||||||
|
import { Group } from 'soapbox/schemas';
|
||||||
|
|
||||||
import { Tabs } from '../components/ui';
|
import { Tabs } from '../components/ui';
|
||||||
|
|
||||||
|
@ -27,6 +28,32 @@ interface IGroupPage {
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PrivacyBlankslate = () => (
|
||||||
|
<Stack space={4} className='py-10' alignItems='center'>
|
||||||
|
<div className='rounded-full bg-gray-200 p-3'>
|
||||||
|
<Icon src={require('@tabler/icons/eye-off.svg')} className='h-6 w-6 text-gray-600' />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Text theme='muted'>
|
||||||
|
Content is only visible to group members
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
|
||||||
|
const BlockedBlankslate = ({ group }: { group: Group }) => (
|
||||||
|
<Stack space={4} className='py-10' alignItems='center'>
|
||||||
|
<div className='rounded-full bg-danger-200 p-3'>
|
||||||
|
<Icon src={require('@tabler/icons/eye-off.svg')} className='h-6 w-6 text-danger-600' />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Text theme='muted'>
|
||||||
|
You are banned from
|
||||||
|
{' '}
|
||||||
|
<Text theme='inherit' tag='span' dangerouslySetInnerHTML={{ __html: group.display_name_html }} />
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
|
||||||
/** Page to display a group. */
|
/** Page to display a group. */
|
||||||
const GroupPage: React.FC<IGroupPage> = ({ params, children }) => {
|
const GroupPage: React.FC<IGroupPage> = ({ params, children }) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
@ -37,7 +64,8 @@ const GroupPage: React.FC<IGroupPage> = ({ params, children }) => {
|
||||||
|
|
||||||
const { group } = useGroup(id);
|
const { group } = useGroup(id);
|
||||||
|
|
||||||
const isNonMember = !group?.relationship?.member;
|
const isMember = !!group?.relationship?.member;
|
||||||
|
const isBlocked = group?.relationship?.blocked_by;
|
||||||
const isPrivate = group?.locked;
|
const isPrivate = group?.locked;
|
||||||
|
|
||||||
// if ((group as any) === false) {
|
// if ((group as any) === false) {
|
||||||
|
@ -59,6 +87,16 @@ const GroupPage: React.FC<IGroupPage> = ({ params, children }) => {
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const renderChildren = () => {
|
||||||
|
if (!isMember && isPrivate) {
|
||||||
|
return <PrivacyBlankslate />;
|
||||||
|
} else if (isBlocked) {
|
||||||
|
return <BlockedBlankslate group={group} />;
|
||||||
|
} else {
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Layout.Main>
|
<Layout.Main>
|
||||||
|
@ -70,17 +108,7 @@ const GroupPage: React.FC<IGroupPage> = ({ params, children }) => {
|
||||||
activeItem={match.path}
|
activeItem={match.path}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{(isNonMember && isPrivate) ? (
|
{renderChildren()}
|
||||||
<Stack space={4} className='py-10' alignItems='center'>
|
|
||||||
<div className='rounded-full bg-gray-200 p-3'>
|
|
||||||
<Icon src={require('@tabler/icons/eye-off.svg')} className='h-6 w-6 text-gray-600' />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Text theme='muted'>
|
|
||||||
Content is only visible to group members
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
) : children}
|
|
||||||
</Column>
|
</Column>
|
||||||
|
|
||||||
{!me && (
|
{!me && (
|
||||||
|
|
124
app/soapbox/schemas/account.ts
Normal file
124
app/soapbox/schemas/account.ts
Normal file
|
@ -0,0 +1,124 @@
|
||||||
|
import escapeTextContentForBrowser from 'escape-html';
|
||||||
|
import z from 'zod';
|
||||||
|
|
||||||
|
import emojify from 'soapbox/features/emoji';
|
||||||
|
|
||||||
|
import { customEmojiSchema } from './custom-emoji';
|
||||||
|
import { relationshipSchema } from './relationship';
|
||||||
|
import { filteredArray, makeCustomEmojiMap } from './utils';
|
||||||
|
|
||||||
|
const avatarMissing = require('assets/images/avatar-missing.png');
|
||||||
|
const headerMissing = require('assets/images/header-missing.png');
|
||||||
|
|
||||||
|
const accountSchema = z.object({
|
||||||
|
accepting_messages: z.boolean().catch(false),
|
||||||
|
accepts_chat_messages: z.boolean().catch(false),
|
||||||
|
acct: z.string().catch(''),
|
||||||
|
avatar: z.string().catch(avatarMissing),
|
||||||
|
avatar_static: z.string().catch(''),
|
||||||
|
birthday: z.string().catch(''),
|
||||||
|
bot: z.boolean().catch(false),
|
||||||
|
chats_onboarded: z.boolean().catch(true),
|
||||||
|
created_at: z.string().datetime().catch(new Date().toUTCString()),
|
||||||
|
discoverable: z.boolean().catch(false),
|
||||||
|
display_name: z.string().catch(''),
|
||||||
|
emojis: filteredArray(customEmojiSchema).catch([]),
|
||||||
|
favicon: z.string().catch(''),
|
||||||
|
fields: z.any(), // TODO
|
||||||
|
followers_count: z.number().catch(0),
|
||||||
|
following_count: z.number().catch(0),
|
||||||
|
fqn: z.string().catch(''),
|
||||||
|
header: z.string().catch(headerMissing),
|
||||||
|
header_static: z.string().catch(''),
|
||||||
|
id: z.string(),
|
||||||
|
last_status_at: z.string().catch(''),
|
||||||
|
location: z.string().catch(''),
|
||||||
|
locked: z.boolean().catch(false),
|
||||||
|
moved: z.any(), // TODO
|
||||||
|
mute_expires_at: z.union([
|
||||||
|
z.string(),
|
||||||
|
z.null(),
|
||||||
|
]).catch(null),
|
||||||
|
note: z.string().catch(''),
|
||||||
|
pleroma: z.any(), // TODO
|
||||||
|
source: z.any(), // TODO
|
||||||
|
statuses_count: z.number().catch(0),
|
||||||
|
uri: z.string().catch(''),
|
||||||
|
url: z.string().catch(''),
|
||||||
|
username: z.string().catch(''),
|
||||||
|
verified: z.boolean().default(false),
|
||||||
|
website: z.string().catch(''),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal fields
|
||||||
|
*/
|
||||||
|
display_name_html: z.string().catch(''),
|
||||||
|
domain: z.string().catch(''),
|
||||||
|
note_emojified: z.string().catch(''),
|
||||||
|
relationship: relationshipSchema.nullable().catch(null),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Misc
|
||||||
|
*/
|
||||||
|
other_settings: z.any(),
|
||||||
|
}).transform((account) => {
|
||||||
|
const customEmojiMap = makeCustomEmojiMap(account.emojis);
|
||||||
|
|
||||||
|
// Birthday
|
||||||
|
const birthday = account.pleroma?.birthday || account.other_settings?.birthday;
|
||||||
|
account.birthday = birthday;
|
||||||
|
|
||||||
|
// Verified
|
||||||
|
const verified = account.verified === true || account.pleroma?.tags?.includes('verified');
|
||||||
|
account.verified = verified;
|
||||||
|
|
||||||
|
// Location
|
||||||
|
const location = account.location
|
||||||
|
|| account.pleroma?.location
|
||||||
|
|| account.other_settings?.location;
|
||||||
|
account.location = location;
|
||||||
|
|
||||||
|
// Username
|
||||||
|
const acct = account.acct || '';
|
||||||
|
const username = account.username || '';
|
||||||
|
account.username = username || acct.split('@')[0];
|
||||||
|
|
||||||
|
// Display Name
|
||||||
|
const displayName = account.display_name || '';
|
||||||
|
account.display_name = displayName.trim().length === 0 ? account.username : displayName;
|
||||||
|
account.display_name_html = emojify(escapeTextContentForBrowser(displayName), customEmojiMap);
|
||||||
|
|
||||||
|
// Discoverable
|
||||||
|
const discoverable = Boolean(account.discoverable || account.source?.pleroma?.discoverable);
|
||||||
|
account.discoverable = discoverable;
|
||||||
|
|
||||||
|
// Message Acceptance
|
||||||
|
const acceptsChatMessages = Boolean(account.pleroma?.accepts_chat_messages || account?.accepting_messages);
|
||||||
|
account.accepts_chat_messages = acceptsChatMessages;
|
||||||
|
|
||||||
|
// Notes
|
||||||
|
account.note_emojified = emojify(account.note, customEmojiMap);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Todo
|
||||||
|
* - internal fields
|
||||||
|
* - donor
|
||||||
|
* - tags
|
||||||
|
* - fields
|
||||||
|
* - pleroma legacy fields
|
||||||
|
* - emojification
|
||||||
|
* - domain
|
||||||
|
* - guessFqn
|
||||||
|
* - fqn
|
||||||
|
* - favicon
|
||||||
|
* - staff fields
|
||||||
|
* - birthday
|
||||||
|
* - note
|
||||||
|
*/
|
||||||
|
|
||||||
|
return account;
|
||||||
|
});
|
||||||
|
|
||||||
|
type Account = z.infer<typeof accountSchema>;
|
||||||
|
|
||||||
|
export { accountSchema, Account };
|
|
@ -1,5 +1,7 @@
|
||||||
import z from 'zod';
|
import z from 'zod';
|
||||||
|
|
||||||
|
import { accountSchema } from './account';
|
||||||
|
|
||||||
enum TruthSocialGroupRoles {
|
enum TruthSocialGroupRoles {
|
||||||
ADMIN = 'owner',
|
ADMIN = 'owner',
|
||||||
MODERATOR = 'admin',
|
MODERATOR = 'admin',
|
||||||
|
@ -14,7 +16,7 @@ enum BaseGroupRoles {
|
||||||
|
|
||||||
const groupMemberSchema = z.object({
|
const groupMemberSchema = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
account: z.any(),
|
account: accountSchema,
|
||||||
role: z.union([
|
role: z.union([
|
||||||
z.nativeEnum(TruthSocialGroupRoles),
|
z.nativeEnum(TruthSocialGroupRoles),
|
||||||
z.nativeEnum(BaseGroupRoles),
|
z.nativeEnum(BaseGroupRoles),
|
||||||
|
|
|
@ -5,6 +5,8 @@ const groupRelationshipSchema = z.object({
|
||||||
member: z.boolean().catch(false),
|
member: z.boolean().catch(false),
|
||||||
requested: z.boolean().catch(false),
|
requested: z.boolean().catch(false),
|
||||||
role: z.string().nullish().catch(null),
|
role: z.string().nullish().catch(null),
|
||||||
|
blocked_by: z.boolean().catch(false),
|
||||||
|
notifying: z.boolean().nullable().catch(null),
|
||||||
});
|
});
|
||||||
|
|
||||||
type GroupRelationship = z.infer<typeof groupRelationshipSchema>;
|
type GroupRelationship = z.infer<typeof groupRelationshipSchema>;
|
||||||
|
|
|
@ -1,15 +1,19 @@
|
||||||
/**
|
/**
|
||||||
* Schemas
|
* Schemas
|
||||||
*/
|
*/
|
||||||
|
export { accountSchema } from './account';
|
||||||
export { customEmojiSchema } from './custom-emoji';
|
export { customEmojiSchema } from './custom-emoji';
|
||||||
export { groupSchema } from './group';
|
export { groupSchema } from './group';
|
||||||
export { groupMemberSchema } from './group-member';
|
export { groupMemberSchema } from './group-member';
|
||||||
export { groupRelationshipSchema } from './group-relationship';
|
export { groupRelationshipSchema } from './group-relationship';
|
||||||
|
export { relationshipSchema } from './relationship';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Entity Types
|
* Entity Types
|
||||||
*/
|
*/
|
||||||
|
export type { Account } from './account';
|
||||||
export type { CustomEmoji } from './custom-emoji';
|
export type { CustomEmoji } from './custom-emoji';
|
||||||
export type { Group } from './group';
|
export type { Group } from './group';
|
||||||
export type { GroupMember } from './group-member';
|
export type { GroupMember } from './group-member';
|
||||||
export type { GroupRelationship } from './group-relationship';
|
export type { GroupRelationship } from './group-relationship';
|
||||||
|
export type { Relationship } from './relationship';
|
||||||
|
|
22
app/soapbox/schemas/relationship.ts
Normal file
22
app/soapbox/schemas/relationship.ts
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
import z from 'zod';
|
||||||
|
|
||||||
|
const relationshipSchema = z.object({
|
||||||
|
blocked_by: z.boolean().catch(false),
|
||||||
|
blocking: z.boolean().catch(false),
|
||||||
|
domain_blocking: z.boolean().catch(false),
|
||||||
|
endorsed: z.boolean().catch(false),
|
||||||
|
followed_by: z.boolean().catch(false),
|
||||||
|
following: z.boolean().catch(false),
|
||||||
|
id: z.string(),
|
||||||
|
muting: z.boolean().catch(false),
|
||||||
|
muting_notifications: z.boolean().catch(false),
|
||||||
|
note: z.string().catch(''),
|
||||||
|
notifying: z.boolean().catch(false),
|
||||||
|
requested: z.boolean().catch(false),
|
||||||
|
showing_reblogs: z.boolean().catch(false),
|
||||||
|
subscribing: z.boolean().catch(false),
|
||||||
|
});
|
||||||
|
|
||||||
|
type Relationship = z.infer<typeof relationshipSchema>;
|
||||||
|
|
||||||
|
export { relationshipSchema, Relationship };
|
|
@ -1,6 +1,7 @@
|
||||||
import type { Account } from 'soapbox/types/entities';
|
import type { Account } from 'soapbox/schemas';
|
||||||
|
import type { Account as AccountEntity } from 'soapbox/types/entities';
|
||||||
|
|
||||||
const getDomainFromURL = (account: Account): string => {
|
const getDomainFromURL = (account: AccountEntity): string => {
|
||||||
try {
|
try {
|
||||||
const url = account.url;
|
const url = account.url;
|
||||||
return new URL(url).host;
|
return new URL(url).host;
|
||||||
|
@ -9,12 +10,12 @@ const getDomainFromURL = (account: Account): string => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getDomain = (account: Account): string => {
|
export const getDomain = (account: AccountEntity): string => {
|
||||||
const domain = account.acct.split('@')[1];
|
const domain = account.acct.split('@')[1];
|
||||||
return domain ? domain : getDomainFromURL(account);
|
return domain ? domain : getDomainFromURL(account);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getBaseURL = (account: Account): string => {
|
export const getBaseURL = (account: AccountEntity): string => {
|
||||||
try {
|
try {
|
||||||
return new URL(account.url).origin;
|
return new URL(account.url).origin;
|
||||||
} catch {
|
} catch {
|
||||||
|
@ -22,16 +23,16 @@ export const getBaseURL = (account: Account): string => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getAcct = (account: Account, displayFqn: boolean): string => (
|
export const getAcct = (account: AccountEntity | Account, displayFqn: boolean): string => (
|
||||||
displayFqn === true ? account.fqn : account.acct
|
displayFqn === true ? account.fqn : account.acct
|
||||||
);
|
);
|
||||||
|
|
||||||
export const isLocal = (account: Account): boolean => {
|
export const isLocal = (account: AccountEntity | Account): boolean => {
|
||||||
const domain: string = account.acct.split('@')[1];
|
const domain: string = account.acct.split('@')[1];
|
||||||
return domain === undefined ? true : false;
|
return domain === undefined ? true : false;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const isRemote = (account: Account): boolean => !isLocal(account);
|
export const isRemote = (account: AccountEntity): boolean => !isLocal(account);
|
||||||
|
|
||||||
/** Default header filenames from various backends */
|
/** Default header filenames from various backends */
|
||||||
const DEFAULT_HEADERS = [
|
const DEFAULT_HEADERS = [
|
||||||
|
|
|
@ -516,6 +516,11 @@ const getInstanceFeatures = (instance: Instance) => {
|
||||||
*/
|
*/
|
||||||
groupsDiscovery: v.software === TRUTHSOCIAL,
|
groupsDiscovery: v.software === TRUTHSOCIAL,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Can kick user from Group.
|
||||||
|
*/
|
||||||
|
groupsKick: v.software !== TRUTHSOCIAL,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Can query pending Group requests.
|
* Can query pending Group requests.
|
||||||
*/
|
*/
|
||||||
|
|
Loading…
Reference in a new issue