Merge branch 'group-fixes' into 'develop'
Various Group fixes & improvements See merge request soapbox-pub/soapbox!2510
This commit is contained in:
commit
0acbbc3445
16 changed files with 117 additions and 44 deletions
|
@ -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 };
|
|
@ -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(() => {
|
||||
|
|
|
@ -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} />
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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 = () =>
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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'>
|
||||
|
|
|
@ -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})` }}
|
||||
/>
|
||||
|
||||
|
|
|
@ -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 />;
|
||||
}
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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",
|
||||
|
|
Loading…
Reference in a new issue