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

View file

@ -7,14 +7,17 @@ import { useEntity } from 'soapbox/entity-store/hooks';
import { useApi, useAppDispatch } from 'soapbox/hooks';
import { type GroupRelationship, groupRelationshipSchema } from 'soapbox/schemas';
function useGroupRelationship(groupId: string) {
function useGroupRelationship(groupId: string | undefined) {
const api = useApi();
const dispatch = useAppDispatch();
const { entity: groupRelationship, ...result } = useEntity<GroupRelationship>(
[Entities.GROUP_RELATIONSHIPS, groupId],
[Entities.GROUP_RELATIONSHIPS, groupId as string],
() => 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(() => {

View file

@ -17,11 +17,16 @@ interface IIcon extends Pick<React.SVGAttributes<SVGAElement>, 'strokeWidth'> {
src: string
/** Width and height of the icon in pixels. */
size?: number
/** Override the data-testid */
'data-testid'?: string
}
/** Renders and SVG icon with optional counter. */
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 ? (
<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} />

View file

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

View file

@ -91,31 +91,32 @@ describe('<GroupTagListItem />', () => {
expect(screen.queryAllByTestId('pin-icon')).toHaveLength(0);
});
});
});
describe('as a non-owner', () => {
const group = buildGroup({
relationship: buildGroupRelationship({
role: GroupRoles.ADMIN,
member: true,
}),
describe('as a non-owner', () => {
const group = buildGroup({
relationship: buildGroupRelationship({
role: GroupRoles.ADMIN,
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', () => {
const tag = buildGroupTag({ visible: true });
describe('when the tag is not pinned', () => {
const tag = buildGroupTag({ pinned: false, visible: true });
it('does not render the pin icon', () => {
render(<GroupTagListItem group={group} tag={tag} isPinnable />);
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);
});
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),
);
},
onError(error) {
const message = (error.response?.data as any).error;
if (message) {
toast.error(message);
}
},
});
const onLeaveGroup = () =>

View file

@ -99,7 +99,7 @@ const GroupHeader: React.FC<IGroupHeader> = ({ group }) => {
if (!isDefaultHeader(group.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}
</a>
);
@ -155,6 +155,7 @@ const GroupHeader: React.FC<IGroupHeader> = ({ group }) => {
theme='muted'
align='center'
dangerouslySetInnerHTML={{ __html: group.note_emojified }}
className='[&_a]:text-primary-600 [&_a]:hover:underline [&_a]:dark:text-accent-blue'
/>
</Stack>

View file

@ -19,6 +19,7 @@ const messages = defineMessages({
leave: { id: 'group.leave.label', defaultMessage: 'Leave' },
leaveSuccess: { id: 'group.leave.success', defaultMessage: 'Left the group' },
report: { id: 'group.report.label', defaultMessage: 'Report' },
share: { id: 'group.share.label', defaultMessage: 'Share' },
});
interface IGroupActionButton {
@ -35,6 +36,15 @@ const GroupOptionsButton = ({ group }: IGroupActionButton) => {
const isAdmin = group.relationship?.role === GroupRoles.ADMIN;
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 = () =>
dispatch(openModal('CONFIRM', {
heading: intl.formatMessage(messages.confirmationHeading),
@ -49,6 +59,7 @@ const GroupOptionsButton = ({ group }: IGroupActionButton) => {
}));
const menu: Menu = useMemo(() => {
const canShare = 'share' in navigator;
const items = [];
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) {
items.push({
text: intl.formatMessage(messages.leave),

View file

@ -3,7 +3,7 @@ import { defineMessages, useIntl } from 'react-intl';
import { Link } from 'react-router-dom';
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 { Entities } from 'soapbox/entity-store/entities';
import { useAppDispatch } from 'soapbox/hooks';
@ -84,6 +84,20 @@ const GroupTagListItem = (props: IGroupMemberListItem) => {
};
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) {
return (
<Tooltip
@ -149,12 +163,12 @@ const GroupTagListItem = (props: IGroupMemberListItem) => {
</Stack>
</Link>
{isOwner ? (
<HStack alignItems='center' space={2}>
{tag.visible ? (
renderPinIcon()
) : null}
<HStack alignItems='center' space={2}>
{tag.visible ? (
renderPinIcon()
) : null}
{isOwner ? (
<Tooltip
text={
tag.visible ?
@ -173,8 +187,8 @@ const GroupTagListItem = (props: IGroupMemberListItem) => {
iconClassName='h-5 w-5 text-primary-500 dark:text-accent-blue'
/>
</Tooltip>
</HStack>
) : null}
) : null}
</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 { 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 intl = useIntl();
const formattedValue = useMemo(() => {
return `#${value}`;
}, [value]);
const handleChange: React.ChangeEventHandler<HTMLInputElement> = ({ target }) => {
onChange(target.value);
onChange(target.value.replace('#', ''));
};
return (
<Input
outerClassName='w-full'
type='text'
value={value}
value={formattedValue}
onChange={handleChange}
placeholder={intl.formatMessage(messages.hashtagPlaceholder)}
autoFocus={autoFocus}

View file

@ -1,7 +1,7 @@
import React, { useEffect, useState } from 'react';
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 { useAppSelector, useInstance } from 'soapbox/hooks';
import { useImageField, useTextField } from 'soapbox/hooks/forms';
@ -36,6 +36,7 @@ const EditGroup: React.FC<IEditGroup> = ({ params: { groupId } }) => {
const { group, isLoading } = useGroup(groupId);
const { updateGroup } = useUpdateGroup(groupId);
const { invalidate } = useGroupTags(groupId);
const [isSubmitting, setIsSubmitting] = useState(false);
const [tags, setTags] = useState<string[]>(['']);
@ -64,6 +65,7 @@ const EditGroup: React.FC<IEditGroup> = ({ params: { groupId } }) => {
tags,
}, {
onSuccess() {
invalidate();
toast.success(intl.formatMessage(messages.groupSaved));
},
onError(error) {

View file

@ -36,7 +36,7 @@ const GroupTopics: React.FC<IGroupTopics> = (props) => {
showLoading={!group || isLoading && tags.length === 0}
placeholderComponent={PlaceholderAccount}
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'
emptyMessage={
<Stack space={4} className='pt-6' justifyContent='center' alignItems='center'>

View file

@ -19,7 +19,7 @@ const GroupLinkPreview: React.FC<IGroupLinkPreview> = ({ card }) => {
return (
<Stack className='cursor-default overflow-hidden rounded-lg border border-gray-300 text-center dark:border-gray-800'>
<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})` }}
/>

View file

@ -10,8 +10,12 @@ import { useAppDispatch } from 'soapbox/hooks';
const ComposeButton = () => {
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 />;
}

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='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 }}
/>
</Stack>

View file

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