Merge branch 'group-fixes' into 'develop'

Various Group fixes & improvements

See merge request soapbox-pub/soapbox!2510
This commit is contained in:
Alex Gleason 2023-05-12 21:43:04 +00:00
commit 0acbbc3445
16 changed files with 117 additions and 44 deletions

View file

@ -3,15 +3,24 @@ import { useEntityLookup } from 'soapbox/entity-store/hooks';
import { useApi } from 'soapbox/hooks/useApi'; import { useApi } from 'soapbox/hooks/useApi';
import { groupSchema } from 'soapbox/schemas'; import { groupSchema } from 'soapbox/schemas';
import { useGroupRelationship } from './useGroupRelationship';
function useGroupLookup(slug: string) { function useGroupLookup(slug: string) {
const api = useApi(); const api = useApi();
return useEntityLookup( const { entity: group, ...result } = useEntityLookup(
Entities.GROUPS, Entities.GROUPS,
(group) => group.slug === slug, (group) => group.slug === slug,
() => api.get(`/api/v1/groups/lookup?name=${slug}`), () => api.get(`/api/v1/groups/lookup?name=${slug}`),
{ schema: groupSchema }, { schema: groupSchema },
); );
const { entity: relationship } = useGroupRelationship(group?.id);
return {
...result,
entity: group ? { ...group, relationship: relationship || null } : undefined,
};
} }
export { useGroupLookup }; export { useGroupLookup };

View file

@ -7,14 +7,17 @@ import { useEntity } from 'soapbox/entity-store/hooks';
import { useApi, useAppDispatch } from 'soapbox/hooks'; import { useApi, useAppDispatch } from 'soapbox/hooks';
import { type GroupRelationship, groupRelationshipSchema } from 'soapbox/schemas'; import { type GroupRelationship, groupRelationshipSchema } from 'soapbox/schemas';
function useGroupRelationship(groupId: string) { function useGroupRelationship(groupId: string | undefined) {
const api = useApi(); const api = useApi();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { entity: groupRelationship, ...result } = useEntity<GroupRelationship>( const { entity: groupRelationship, ...result } = useEntity<GroupRelationship>(
[Entities.GROUP_RELATIONSHIPS, groupId], [Entities.GROUP_RELATIONSHIPS, groupId as string],
() => api.get(`/api/v1/groups/relationships?id[]=${groupId}`), () => api.get(`/api/v1/groups/relationships?id[]=${groupId}`),
{ schema: z.array(groupRelationshipSchema).transform(arr => arr[0]) }, {
enabled: !!groupId,
schema: z.array(groupRelationshipSchema).transform(arr => arr[0]),
},
); );
useEffect(() => { useEffect(() => {

View file

@ -17,11 +17,16 @@ interface IIcon extends Pick<React.SVGAttributes<SVGAElement>, 'strokeWidth'> {
src: string src: string
/** Width and height of the icon in pixels. */ /** Width and height of the icon in pixels. */
size?: number size?: number
/** Override the data-testid */
'data-testid'?: string
} }
/** Renders and SVG icon with optional counter. */ /** Renders and SVG icon with optional counter. */
const Icon: React.FC<IIcon> = ({ src, alt, count, size, countMax, ...filteredProps }): JSX.Element => ( const Icon: React.FC<IIcon> = ({ src, alt, count, size, countMax, ...filteredProps }): JSX.Element => (
<div className='relative flex shrink-0 flex-col' data-testid='icon'> <div
className='relative flex shrink-0 flex-col'
data-testid={filteredProps['data-testid'] || 'icon'}
>
{count ? ( {count ? (
<span className='absolute -right-3 -top-2 flex h-5 min-w-[20px] shrink-0 items-center justify-center whitespace-nowrap break-words'> <span className='absolute -right-3 -top-2 flex h-5 min-w-[20px] shrink-0 items-center justify-center whitespace-nowrap break-words'>
<Counter count={count} countMax={countMax} /> <Counter count={count} countMax={countMax} />

View file

@ -14,6 +14,8 @@ interface UseEntityOpts<TEntity extends Entity> {
schema?: EntitySchema<TEntity> schema?: EntitySchema<TEntity>
/** Whether to refetch this entity every time the hook mounts, even if it's already in the store. */ /** Whether to refetch this entity every time the hook mounts, even if it's already in the store. */
refetch?: boolean refetch?: boolean
/** A flag to potentially disable sending requests to the API. */
enabled?: boolean
} }
function useEntity<TEntity extends Entity>( function useEntity<TEntity extends Entity>(
@ -31,6 +33,7 @@ function useEntity<TEntity extends Entity>(
const entity = useAppSelector(state => state.entities[entityType]?.store[entityId] as TEntity | undefined); const entity = useAppSelector(state => state.entities[entityType]?.store[entityId] as TEntity | undefined);
const isEnabled = opts.enabled ?? true;
const isLoading = isFetching && !entity; const isLoading = isFetching && !entity;
const fetchEntity = async () => { const fetchEntity = async () => {
@ -44,10 +47,11 @@ function useEntity<TEntity extends Entity>(
}; };
useEffect(() => { useEffect(() => {
if (!isEnabled) return;
if (!entity || opts.refetch) { if (!entity || opts.refetch) {
fetchEntity(); fetchEntity();
} }
}, []); }, [isEnabled]);
return { return {
entity, entity,

View file

@ -91,31 +91,32 @@ describe('<GroupTagListItem />', () => {
expect(screen.queryAllByTestId('pin-icon')).toHaveLength(0); expect(screen.queryAllByTestId('pin-icon')).toHaveLength(0);
}); });
}); });
});
describe('as a non-owner', () => { describe('as a non-owner', () => {
const group = buildGroup({ const group = buildGroup({
relationship: buildGroupRelationship({ relationship: buildGroupRelationship({
role: GroupRoles.ADMIN, role: GroupRoles.ADMIN,
member: true, member: true,
}), }),
});
describe('when the tag is pinned', () => {
const tag = buildGroupTag({ pinned: true, visible: true });
it('does render the pin icon', () => {
render(<GroupTagListItem group={group} tag={tag} isPinnable />);
screen.debug();
expect(screen.queryAllByTestId('pin-icon')).toHaveLength(1);
}); });
});
describe('when the tag is visible', () => { describe('when the tag is not pinned', () => {
const tag = buildGroupTag({ visible: true }); const tag = buildGroupTag({ pinned: false, visible: true });
it('does not render the pin icon', () => { it('does not render the pin icon', () => {
render(<GroupTagListItem group={group} tag={tag} isPinnable />); render(<GroupTagListItem group={group} tag={tag} isPinnable />);
expect(screen.queryAllByTestId('pin-icon')).toHaveLength(0); expect(screen.queryAllByTestId('pin-icon')).toHaveLength(0);
});
});
describe('when the tag is not visible', () => {
const tag = buildGroupTag({ visible: false });
it('does not render the pin icon', () => {
render(<GroupTagListItem group={group} tag={tag} isPinnable />);
expect(screen.queryAllByTestId('pin-icon')).toHaveLength(0);
});
}); });
}); });
}); });

View file

@ -55,6 +55,12 @@ const GroupActionButton = ({ group }: IGroupActionButton) => {
: intl.formatMessage(messages.joinSuccess), : intl.formatMessage(messages.joinSuccess),
); );
}, },
onError(error) {
const message = (error.response?.data as any).error;
if (message) {
toast.error(message);
}
},
}); });
const onLeaveGroup = () => const onLeaveGroup = () =>

View file

@ -99,7 +99,7 @@ const GroupHeader: React.FC<IGroupHeader> = ({ group }) => {
if (!isDefaultHeader(group.header)) { if (!isDefaultHeader(group.header)) {
header = ( header = (
<a href={group.header} onClick={handleHeaderClick} target='_blank' className='relative'> <a href={group.header} onClick={handleHeaderClick} target='_blank' className='relative w-full'>
{header} {header}
</a> </a>
); );
@ -155,6 +155,7 @@ const GroupHeader: React.FC<IGroupHeader> = ({ group }) => {
theme='muted' theme='muted'
align='center' align='center'
dangerouslySetInnerHTML={{ __html: group.note_emojified }} dangerouslySetInnerHTML={{ __html: group.note_emojified }}
className='[&_a]:text-primary-600 [&_a]:hover:underline [&_a]:dark:text-accent-blue'
/> />
</Stack> </Stack>

View file

@ -19,6 +19,7 @@ const messages = defineMessages({
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' },
report: { id: 'group.report.label', defaultMessage: 'Report' }, report: { id: 'group.report.label', defaultMessage: 'Report' },
share: { id: 'group.share.label', defaultMessage: 'Share' },
}); });
interface IGroupActionButton { interface IGroupActionButton {
@ -35,6 +36,15 @@ const GroupOptionsButton = ({ group }: IGroupActionButton) => {
const isAdmin = group.relationship?.role === GroupRoles.ADMIN; const isAdmin = group.relationship?.role === GroupRoles.ADMIN;
const isBlocked = group.relationship?.blocked_by; const isBlocked = group.relationship?.blocked_by;
const handleShare = () => {
navigator.share({
text: group.display_name,
url: group.url,
}).catch((e) => {
if (e.name !== 'AbortError') console.error(e);
});
};
const onLeaveGroup = () => const onLeaveGroup = () =>
dispatch(openModal('CONFIRM', { dispatch(openModal('CONFIRM', {
heading: intl.formatMessage(messages.confirmationHeading), heading: intl.formatMessage(messages.confirmationHeading),
@ -49,6 +59,7 @@ const GroupOptionsButton = ({ group }: IGroupActionButton) => {
})); }));
const menu: Menu = useMemo(() => { const menu: Menu = useMemo(() => {
const canShare = 'share' in navigator;
const items = []; const items = [];
if (isMember || isAdmin) { if (isMember || isAdmin) {
@ -59,6 +70,14 @@ const GroupOptionsButton = ({ group }: IGroupActionButton) => {
}); });
} }
if (canShare) {
items.push({
text: intl.formatMessage(messages.share),
icon: require('@tabler/icons/share.svg'),
action: handleShare,
});
}
if (isAdmin) { if (isAdmin) {
items.push({ items.push({
text: intl.formatMessage(messages.leave), text: intl.formatMessage(messages.leave),

View file

@ -3,7 +3,7 @@ import { defineMessages, useIntl } from 'react-intl';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useUpdateGroupTag } from 'soapbox/api/hooks'; import { useUpdateGroupTag } from 'soapbox/api/hooks';
import { HStack, IconButton, Stack, Text, Tooltip } from 'soapbox/components/ui'; import { HStack, Icon, IconButton, Stack, Text, Tooltip } from 'soapbox/components/ui';
import { importEntities } from 'soapbox/entity-store/actions'; import { importEntities } from 'soapbox/entity-store/actions';
import { Entities } from 'soapbox/entity-store/entities'; import { Entities } from 'soapbox/entity-store/entities';
import { useAppDispatch } from 'soapbox/hooks'; import { useAppDispatch } from 'soapbox/hooks';
@ -84,6 +84,20 @@ const GroupTagListItem = (props: IGroupMemberListItem) => {
}; };
const renderPinIcon = () => { const renderPinIcon = () => {
if (!isOwner && tag.pinned) {
return (
<Icon
src={require('@tabler/icons/pin-filled.svg')}
className='h-5 w-5 text-gray-600'
data-testid='pin-icon'
/>
);
}
if (!isOwner) {
return null;
}
if (isPinnable) { if (isPinnable) {
return ( return (
<Tooltip <Tooltip
@ -149,12 +163,12 @@ const GroupTagListItem = (props: IGroupMemberListItem) => {
</Stack> </Stack>
</Link> </Link>
{isOwner ? ( <HStack alignItems='center' space={2}>
<HStack alignItems='center' space={2}> {tag.visible ? (
{tag.visible ? ( renderPinIcon()
renderPinIcon() ) : null}
) : null}
{isOwner ? (
<Tooltip <Tooltip
text={ text={
tag.visible ? tag.visible ?
@ -173,8 +187,8 @@ const GroupTagListItem = (props: IGroupMemberListItem) => {
iconClassName='h-5 w-5 text-primary-500 dark:text-accent-blue' iconClassName='h-5 w-5 text-primary-500 dark:text-accent-blue'
/> />
</Tooltip> </Tooltip>
</HStack> ) : null}
) : null} </HStack>
</HStack> </HStack>
); );
}; };

View file

@ -1,4 +1,4 @@
import React from 'react'; import React, { useMemo } from 'react';
import { FormattedMessage, defineMessages, useIntl } from 'react-intl'; import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
import { Input, Streamfield } from 'soapbox/components/ui'; import { Input, Streamfield } from 'soapbox/components/ui';
@ -36,15 +36,19 @@ const GroupTagsField: React.FC<IGroupTagsField> = ({ tags, onChange, onAddItem,
const HashtagField: StreamfieldComponent<string> = ({ value, onChange, autoFocus = false }) => { const HashtagField: StreamfieldComponent<string> = ({ value, onChange, autoFocus = false }) => {
const intl = useIntl(); const intl = useIntl();
const formattedValue = useMemo(() => {
return `#${value}`;
}, [value]);
const handleChange: React.ChangeEventHandler<HTMLInputElement> = ({ target }) => { const handleChange: React.ChangeEventHandler<HTMLInputElement> = ({ target }) => {
onChange(target.value); onChange(target.value.replace('#', ''));
}; };
return ( return (
<Input <Input
outerClassName='w-full' outerClassName='w-full'
type='text' type='text'
value={value} value={formattedValue}
onChange={handleChange} onChange={handleChange}
placeholder={intl.formatMessage(messages.hashtagPlaceholder)} placeholder={intl.formatMessage(messages.hashtagPlaceholder)}
autoFocus={autoFocus} autoFocus={autoFocus}

View file

@ -1,7 +1,7 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { useGroup, useUpdateGroup } from 'soapbox/api/hooks'; import { useGroup, useGroupTags, useUpdateGroup } from 'soapbox/api/hooks';
import { Button, Column, Form, FormActions, FormGroup, Icon, Input, Spinner, Textarea } from 'soapbox/components/ui'; import { Button, Column, Form, FormActions, FormGroup, Icon, Input, Spinner, Textarea } from 'soapbox/components/ui';
import { useAppSelector, useInstance } from 'soapbox/hooks'; import { useAppSelector, useInstance } from 'soapbox/hooks';
import { useImageField, useTextField } from 'soapbox/hooks/forms'; import { useImageField, useTextField } from 'soapbox/hooks/forms';
@ -36,6 +36,7 @@ const EditGroup: React.FC<IEditGroup> = ({ params: { groupId } }) => {
const { group, isLoading } = useGroup(groupId); const { group, isLoading } = useGroup(groupId);
const { updateGroup } = useUpdateGroup(groupId); const { updateGroup } = useUpdateGroup(groupId);
const { invalidate } = useGroupTags(groupId);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [tags, setTags] = useState<string[]>(['']); const [tags, setTags] = useState<string[]>(['']);
@ -64,6 +65,7 @@ const EditGroup: React.FC<IEditGroup> = ({ params: { groupId } }) => {
tags, tags,
}, { }, {
onSuccess() { onSuccess() {
invalidate();
toast.success(intl.formatMessage(messages.groupSaved)); toast.success(intl.formatMessage(messages.groupSaved));
}, },
onError(error) { onError(error) {

View file

@ -36,7 +36,7 @@ const GroupTopics: React.FC<IGroupTopics> = (props) => {
showLoading={!group || isLoading && tags.length === 0} showLoading={!group || isLoading && tags.length === 0}
placeholderComponent={PlaceholderAccount} placeholderComponent={PlaceholderAccount}
placeholderCount={3} placeholderCount={3}
className='divide-y divide-solid divide-gray-300' className='divide-y divide-solid divide-gray-300 dark:divide-gray-800'
itemClassName='py-3 last:pb-0' itemClassName='py-3 last:pb-0'
emptyMessage={ emptyMessage={
<Stack space={4} className='pt-6' justifyContent='center' alignItems='center'> <Stack space={4} className='pt-6' justifyContent='center' alignItems='center'>

View file

@ -19,7 +19,7 @@ const GroupLinkPreview: React.FC<IGroupLinkPreview> = ({ card }) => {
return ( return (
<Stack className='cursor-default overflow-hidden rounded-lg border border-gray-300 text-center dark:border-gray-800'> <Stack className='cursor-default overflow-hidden rounded-lg border border-gray-300 text-center dark:border-gray-800'>
<div <div
className='-mb-8 h-32 w-full bg-center' className='-mb-8 h-32 w-full bg-cover bg-center'
style={{ backgroundImage: `url(${group.header})` }} style={{ backgroundImage: `url(${group.header})` }}
/> />

View file

@ -10,8 +10,12 @@ import { useAppDispatch } from 'soapbox/hooks';
const ComposeButton = () => { const ComposeButton = () => {
const location = useLocation(); const location = useLocation();
const isOnGroupPage = location.pathname.startsWith('/group/');
const match = useRouteMatch<{ groupSlug: string }>('/group/:groupSlug');
const { entity: group } = useGroupLookup(match?.params.groupSlug || '');
const isGroupMember = !!group?.relationship?.member;
if (location.pathname.startsWith('/group/')) { if (isOnGroupPage && isGroupMember) {
return <GroupComposeButton />; return <GroupComposeButton />;
} }

View file

@ -56,7 +56,7 @@ const ConfirmationStep: React.FC<IConfirmationStep> = ({ group }) => {
<Text size='2xl' weight='bold' align='center'>{group.display_name}</Text> <Text size='2xl' weight='bold' align='center'>{group.display_name}</Text>
<Text <Text
size='md' size='md'
className='mx-auto max-w-sm' className='mx-auto max-w-sm [&_a]:text-primary-600 [&_a]:hover:underline [&_a]:dark:text-accent-blue'
dangerouslySetInnerHTML={{ __html: group.note_emojified }} dangerouslySetInnerHTML={{ __html: group.note_emojified }}
/> />
</Stack> </Stack>

View file

@ -807,6 +807,7 @@
"group.report.label": "Report", "group.report.label": "Report",
"group.role.admin": "Admin", "group.role.admin": "Admin",
"group.role.owner": "Owner", "group.role.owner": "Owner",
"group.share.label": "Share",
"group.tabs.all": "All", "group.tabs.all": "All",
"group.tabs.media": "Media", "group.tabs.media": "Media",
"group.tabs.members": "Members", "group.tabs.members": "Members",