diff --git a/app/soapbox/api/hooks/groups/useGroupLookup.ts b/app/soapbox/api/hooks/groups/useGroupLookup.ts index 89c778a15..6e41975e5 100644 --- a/app/soapbox/api/hooks/groups/useGroupLookup.ts +++ b/app/soapbox/api/hooks/groups/useGroupLookup.ts @@ -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 }; \ No newline at end of file diff --git a/app/soapbox/api/hooks/groups/useGroupRelationship.ts b/app/soapbox/api/hooks/groups/useGroupRelationship.ts index 6b24c463c..21d8d3efd 100644 --- a/app/soapbox/api/hooks/groups/useGroupRelationship.ts +++ b/app/soapbox/api/hooks/groups/useGroupRelationship.ts @@ -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( - [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(() => { diff --git a/app/soapbox/components/ui/icon/icon.tsx b/app/soapbox/components/ui/icon/icon.tsx index f51c3ca38..709b3126f 100644 --- a/app/soapbox/components/ui/icon/icon.tsx +++ b/app/soapbox/components/ui/icon/icon.tsx @@ -17,11 +17,16 @@ interface IIcon extends Pick, '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 = ({ src, alt, count, size, countMax, ...filteredProps }): JSX.Element => ( -
+
{count ? ( diff --git a/app/soapbox/entity-store/hooks/useEntity.ts b/app/soapbox/entity-store/hooks/useEntity.ts index 63447ae67..3d57c8ab0 100644 --- a/app/soapbox/entity-store/hooks/useEntity.ts +++ b/app/soapbox/entity-store/hooks/useEntity.ts @@ -14,6 +14,8 @@ interface UseEntityOpts { schema?: EntitySchema /** 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( @@ -31,6 +33,7 @@ function useEntity( 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( }; useEffect(() => { + if (!isEnabled) return; if (!entity || opts.refetch) { fetchEntity(); } - }, []); + }, [isEnabled]); return { entity, diff --git a/app/soapbox/features/group/components/__tests__/group-tag-list-item.test.tsx b/app/soapbox/features/group/components/__tests__/group-tag-list-item.test.tsx index 4418fff86..f91853dc4 100644 --- a/app/soapbox/features/group/components/__tests__/group-tag-list-item.test.tsx +++ b/app/soapbox/features/group/components/__tests__/group-tag-list-item.test.tsx @@ -91,31 +91,32 @@ describe('', () => { 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(); + 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(); - 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(); - expect(screen.queryAllByTestId('pin-icon')).toHaveLength(0); - }); + it('does not render the pin icon', () => { + render(); + expect(screen.queryAllByTestId('pin-icon')).toHaveLength(0); }); }); }); diff --git a/app/soapbox/features/group/components/group-action-button.tsx b/app/soapbox/features/group/components/group-action-button.tsx index 005b9e245..f3b208574 100644 --- a/app/soapbox/features/group/components/group-action-button.tsx +++ b/app/soapbox/features/group/components/group-action-button.tsx @@ -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 = () => diff --git a/app/soapbox/features/group/components/group-header.tsx b/app/soapbox/features/group/components/group-header.tsx index ad22e1e13..2491a7bb7 100644 --- a/app/soapbox/features/group/components/group-header.tsx +++ b/app/soapbox/features/group/components/group-header.tsx @@ -99,7 +99,7 @@ const GroupHeader: React.FC = ({ group }) => { if (!isDefaultHeader(group.header)) { header = ( - + {header} ); @@ -155,6 +155,7 @@ const GroupHeader: React.FC = ({ group }) => { theme='muted' align='center' dangerouslySetInnerHTML={{ __html: group.note_emojified }} + className='[&_a]:text-primary-600 [&_a]:hover:underline [&_a]:dark:text-accent-blue' /> diff --git a/app/soapbox/features/group/components/group-options-button.tsx b/app/soapbox/features/group/components/group-options-button.tsx index 597a751d7..ebc3152e4 100644 --- a/app/soapbox/features/group/components/group-options-button.tsx +++ b/app/soapbox/features/group/components/group-options-button.tsx @@ -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), diff --git a/app/soapbox/features/group/components/group-tag-list-item.tsx b/app/soapbox/features/group/components/group-tag-list-item.tsx index bf02cc202..07660cf21 100644 --- a/app/soapbox/features/group/components/group-tag-list-item.tsx +++ b/app/soapbox/features/group/components/group-tag-list-item.tsx @@ -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 ( + + ); + } + + if (!isOwner) { + return null; + } + if (isPinnable) { return ( { - {isOwner ? ( - - {tag.visible ? ( - renderPinIcon() - ) : null} + + {tag.visible ? ( + renderPinIcon() + ) : null} + {isOwner ? ( { iconClassName='h-5 w-5 text-primary-500 dark:text-accent-blue' /> - - ) : null} + ) : null} + ); }; diff --git a/app/soapbox/features/group/components/group-tags-field.tsx b/app/soapbox/features/group/components/group-tags-field.tsx index ba98d808e..f8092d5c0 100644 --- a/app/soapbox/features/group/components/group-tags-field.tsx +++ b/app/soapbox/features/group/components/group-tags-field.tsx @@ -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 = ({ tags, onChange, onAddItem, const HashtagField: StreamfieldComponent = ({ value, onChange, autoFocus = false }) => { const intl = useIntl(); + const formattedValue = useMemo(() => { + return `#${value}`; + }, [value]); + const handleChange: React.ChangeEventHandler = ({ target }) => { - onChange(target.value); + onChange(target.value.replace('#', '')); }; return ( = ({ params: { groupId } }) => { const { group, isLoading } = useGroup(groupId); const { updateGroup } = useUpdateGroup(groupId); + const { invalidate } = useGroupTags(groupId); const [isSubmitting, setIsSubmitting] = useState(false); const [tags, setTags] = useState(['']); @@ -64,6 +65,7 @@ const EditGroup: React.FC = ({ params: { groupId } }) => { tags, }, { onSuccess() { + invalidate(); toast.success(intl.formatMessage(messages.groupSaved)); }, onError(error) { diff --git a/app/soapbox/features/group/group-tags.tsx b/app/soapbox/features/group/group-tags.tsx index 710a4fdb5..d5335e844 100644 --- a/app/soapbox/features/group/group-tags.tsx +++ b/app/soapbox/features/group/group-tags.tsx @@ -36,7 +36,7 @@ const GroupTopics: React.FC = (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={ diff --git a/app/soapbox/features/groups/components/group-link-preview.tsx b/app/soapbox/features/groups/components/group-link-preview.tsx index 18ca586a5..98ca03076 100644 --- a/app/soapbox/features/groups/components/group-link-preview.tsx +++ b/app/soapbox/features/groups/components/group-link-preview.tsx @@ -19,7 +19,7 @@ const GroupLinkPreview: React.FC = ({ card }) => { return (
diff --git a/app/soapbox/features/ui/components/compose-button.tsx b/app/soapbox/features/ui/components/compose-button.tsx index a21909bb1..7686f9c55 100644 --- a/app/soapbox/features/ui/components/compose-button.tsx +++ b/app/soapbox/features/ui/components/compose-button.tsx @@ -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 ; } diff --git a/app/soapbox/features/ui/components/modals/manage-group-modal/steps/confirmation-step.tsx b/app/soapbox/features/ui/components/modals/manage-group-modal/steps/confirmation-step.tsx index 11244c3c5..785da5da1 100644 --- a/app/soapbox/features/ui/components/modals/manage-group-modal/steps/confirmation-step.tsx +++ b/app/soapbox/features/ui/components/modals/manage-group-modal/steps/confirmation-step.tsx @@ -56,7 +56,7 @@ const ConfirmationStep: React.FC = ({ group }) => { {group.display_name} diff --git a/app/soapbox/locales/en.json b/app/soapbox/locales/en.json index 919b33901..879294583 100644 --- a/app/soapbox/locales/en.json +++ b/app/soapbox/locales/en.json @@ -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",