Add Account entity and improve Block/Ban support for Groups

environments/review-block-grou-jxb1rh/deployments/2872
Chewbacca 2 years ago
parent a99a7b2af5
commit 6b30671875

@ -14,10 +14,11 @@ import RelativeTimestamp from './relative-timestamp';
import { Avatar, Emoji, HStack, Icon, IconButton, Stack, Text } from './ui';
import type { StatusApprovalStatus } from 'soapbox/normalizers/status';
import type { Account as AccountSchema } from 'soapbox/schemas';
import type { Account as AccountEntity } from 'soapbox/types/entities';
interface IInstanceFavicon {
account: AccountEntity
account: AccountEntity | AccountSchema
disabled?: boolean
}
@ -67,7 +68,7 @@ const ProfilePopper: React.FC<IProfilePopper> = ({ condition, wrapper, children
};
export interface IAccount {
account: AccountEntity
account: AccountEntity | AccountSchema
action?: React.ReactElement
actionAlignment?: 'center' | 'top'
actionIcon?: string

@ -73,7 +73,7 @@ const DropdownMenuItem = ({ index, item, onClick }: IDropdownMenuItem) => {
}
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
href={item.href || item.to || '#'}
role='button'

@ -28,6 +28,7 @@ const GroupActionButton = ({ group }: IGroupActionButton) => {
const isRequested = group.relationship?.requested;
const isNonMember = !group.relationship?.member && !isRequested;
const isAdmin = group.relationship?.role === 'admin';
const isBlocked = group.relationship?.blocked_by;
const onJoinGroup = () => joinGroup.mutate(group);
@ -41,6 +42,10 @@ const GroupActionButton = ({ group }: IGroupActionButton) => {
const onCancelRequest = () => cancelRequest.mutate(group);
if (isBlocked) {
return null;
}
if (isNonMember) {
return (
<Button

@ -1,14 +1,16 @@
import clsx from 'clsx';
import React, { useMemo } from 'react';
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 Account from 'soapbox/components/account';
import { HStack, IconButton, Menu, MenuButton, MenuDivider, MenuItem, MenuLink, MenuList } from 'soapbox/components/ui';
import SvgIcon from 'soapbox/components/ui/icon/svg-icon';
import { useAccount, useAppDispatch } from 'soapbox/hooks';
import DropdownMenu from 'soapbox/components/dropdown-menu/dropdown-menu';
import { HStack } from 'soapbox/components/ui';
import { deleteEntities } from 'soapbox/entity-store/actions';
import { Entities } from 'soapbox/entity-store/entities';
import { useAccount, useAppDispatch, useFeatures } from 'soapbox/hooks';
import { useBlockGroupMember } from 'soapbox/hooks/api/groups/useBlockGroupMember';
import { BaseGroupRoles, useGroupRoles } from 'soapbox/hooks/useGroupRoles';
import toast from 'soapbox/toast';
@ -17,10 +19,11 @@ import type { Account as AccountEntity, Group, GroupMember } from 'soapbox/types
const messages = defineMessages({
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?' },
blocked: { id: 'group.group_mod_block.success', defaultMessage: 'Blocked @{name} from group' },
blockFromGroupHeading: { id: 'confirmations.block_from_group.heading', defaultMessage: 'Ban 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' },
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}' },
groupModKick: { id: 'group.group_mod_kick', defaultMessage: 'Kick @{name} from group' },
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 dispatch = useAppDispatch();
const features = useFeatures();
const intl = useIntl();
const { normalizeRole } = useGroupRoles();
const blockGroupMember = useBlockGroupMember(group, member);
const account = useAccount(member.account.id) as AccountEntity;
@ -70,11 +75,13 @@ const GroupMemberListItem = (props: IGroupMemberListItem) => {
const handleBlockFromGroup = () => {
dispatch(openModal('CONFIRM', {
heading: intl.formatMessage(messages.blockFromGroupHeading),
message: intl.formatMessage(messages.blockFromGroupMessage, { name: account.username }),
confirm: intl.formatMessage(messages.blockConfirm),
onConfirm: () => dispatch(groupBlock(group.id, account.id)).then(() =>
toast.success(intl.formatMessage(messages.blocked, { name: account.acct })),
),
onConfirm: () => blockGroupMember({ account_ids: [member.account.id] }).then(() => {
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) &&
member.role !== group.relationship.role
) {
items.push({
text: intl.formatMessage(messages.groupModKick, { name: account.username }),
icon: require('@tabler/icons/user-minus.svg'),
action: handleKickFromGroup,
});
if (features.groupsKick) {
items.push({
text: intl.formatMessage(messages.groupModKick, { name: account.username }),
icon: require('@tabler/icons/user-minus.svg'),
action: handleKickFromGroup,
});
}
items.push({
text: intl.formatMessage(messages.groupModBlock, { name: account.username }),
icon: require('@tabler/icons/ban.svg'),
action: handleBlockFromGroup,
destructive: true,
});
}
@ -176,40 +187,7 @@ const GroupMemberListItem = (props: IGroupMemberListItem) => {
</span>
) : null}
{menu.length > 0 && (
<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>
)}
<DropdownMenu items={menu} />
</HStack>
</HStack>
);

@ -50,7 +50,7 @@ const GroupMembers: React.FC<IGroupMembers> = (props) => {
<GroupMemberListItem
group={group as Group}
member={member}
key={member?.account}
key={member.account.id}
/>
))}
</ScrollableList>

@ -15,6 +15,7 @@ import { openModal } from 'soapbox/actions/modals';
import { Button, HStack } from 'soapbox/components/ui';
import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks';
import type { Account } from 'soapbox/schemas';
import type { Account as AccountEntity } from 'soapbox/types/entities';
const messages = defineMessages({
@ -35,7 +36,7 @@ const messages = defineMessages({
interface IActionButton {
/** Target account for the action. */
account: AccountEntity
account: AccountEntity | Account
/** Type of action to prioritize, eg on Blocks and Mutes pages. */
actionType?: 'muting' | 'blocking' | 'follow_request'
/** Displays shorter text on the "Awaiting approval" button. */

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

@ -48,7 +48,7 @@ function useGroupRelationships(groupIds: string[]) {
const q = groupIds.map(id => `id[]=${id}`).join('&');
const endpoint = groupIds.length ? `/api/v1/groups/relationships?${q}` : undefined;
const { entities, ...result } = useEntities<GroupRelationship>(
[Entities.GROUP_RELATIONSHIPS, q],
[Entities.GROUP_RELATIONSHIPS, ...groupIds],
endpoint,
{ schema: groupRelationshipSchema },
);

@ -472,7 +472,7 @@
"confirmations.block.message": "Are you sure you want to block {name}?",
"confirmations.block_from_group.confirm": "Block",
"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.heading": "Discard post",
"confirmations.cancel.message": "Are you sure you want to cancel creating this post?",
@ -770,8 +770,8 @@
"group.cancel_request": "Cancel Request",
"group.group_mod_authorize": "Accept",
"group.group_mod_authorize.success": "Accepted @{name} to group",
"group.group_mod_block": "Block @{name} from group",
"group.group_mod_block.success": "Blocked @{name} from group",
"group.group_mod_block": "Ban from group",
"group.group_mod_block.success": "You have successfully blocked @{name} from the group",
"group.group_mod_demote": "Demote @{name}",
"group.group_mod_demote.success": "Demoted @{name} to group user",
"group.group_mod_kick": "Kick @{name} from group",

@ -12,6 +12,7 @@ import {
SignUpPanel,
} from 'soapbox/features/ui/util/async-components';
import { useGroup, useOwnAccount } from 'soapbox/hooks';
import { Group } from 'soapbox/schemas';
import { Tabs } from '../components/ui';
@ -27,6 +28,32 @@ interface IGroupPage {
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. */
const GroupPage: React.FC<IGroupPage> = ({ params, children }) => {
const intl = useIntl();
@ -37,7 +64,8 @@ const GroupPage: React.FC<IGroupPage> = ({ params, children }) => {
const { group } = useGroup(id);
const isNonMember = !group?.relationship?.member;
const isMember = !!group?.relationship?.member;
const isBlocked = group?.relationship?.blocked_by;
const isPrivate = group?.locked;
// 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 (
<>
<Layout.Main>
@ -70,17 +108,7 @@ const GroupPage: React.FC<IGroupPage> = ({ params, children }) => {
activeItem={match.path}
/>
{(isNonMember && isPrivate) ? (
<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}
{renderChildren()}
</Column>
{!me && (

@ -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 { accountSchema } from './account';
enum TruthSocialGroupRoles {
ADMIN = 'owner',
MODERATOR = 'admin',
@ -14,7 +16,7 @@ enum BaseGroupRoles {
const groupMemberSchema = z.object({
id: z.string(),
account: z.any(),
account: accountSchema,
role: z.union([
z.nativeEnum(TruthSocialGroupRoles),
z.nativeEnum(BaseGroupRoles),

@ -5,6 +5,8 @@ const groupRelationshipSchema = z.object({
member: z.boolean().catch(false),
requested: z.boolean().catch(false),
role: z.string().nullish().catch(null),
blocked_by: z.boolean().catch(false),
notifying: z.boolean().nullable().catch(null),
});
type GroupRelationship = z.infer<typeof groupRelationshipSchema>;

@ -1,15 +1,19 @@
/**
* Schemas
*/
export { accountSchema } from './account';
export { customEmojiSchema } from './custom-emoji';
export { groupSchema } from './group';
export { groupMemberSchema } from './group-member';
export { groupRelationshipSchema } from './group-relationship';
export { relationshipSchema } from './relationship';
/**
* Entity Types
*/
export type { Account } from './account';
export type { CustomEmoji } from './custom-emoji';
export type { Group } from './group';
export type { GroupMember } from './group-member';
export type { GroupRelationship } from './group-relationship';
export type { Relationship } from './relationship';

@ -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 {
const url = account.url;
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];
return domain ? domain : getDomainFromURL(account);
};
export const getBaseURL = (account: Account): string => {
export const getBaseURL = (account: AccountEntity): string => {
try {
return new URL(account.url).origin;
} 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
);
export const isLocal = (account: Account): boolean => {
export const isLocal = (account: AccountEntity | Account): boolean => {
const domain: string = account.acct.split('@')[1];
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 */
const DEFAULT_HEADERS = [

@ -516,6 +516,11 @@ const getInstanceFeatures = (instance: Instance) => {
*/
groupsDiscovery: v.software === TRUTHSOCIAL,
/**
* Can kick user from Group.
*/
groupsKick: v.software !== TRUTHSOCIAL,
/**
* Can query pending Group requests.
*/

Loading…
Cancel
Save