diff --git a/app/soapbox/features/group/components/__tests__/group-action-button.test.tsx b/app/soapbox/features/group/components/__tests__/group-action-button.test.tsx
new file mode 100644
index 000000000..eb2cf670b
--- /dev/null
+++ b/app/soapbox/features/group/components/__tests__/group-action-button.test.tsx
@@ -0,0 +1,130 @@
+import React from 'react';
+
+import { render, screen } from 'soapbox/jest/test-helpers';
+import { normalizeGroup, normalizeGroupRelationship } from 'soapbox/normalizers';
+import { Group } from 'soapbox/types/entities';
+
+import GroupActionButton from '../group-action-button';
+
+let group: Group;
+
+describe('', () => {
+ describe('with no group relationship', () => {
+ beforeEach(() => {
+ group = normalizeGroup({
+ relationship: null,
+ });
+ });
+
+ describe('with a private group', () => {
+ beforeEach(() => {
+ group = group.set('locked', true);
+ });
+
+ it('should render the Request Access button', () => {
+ render();
+
+ expect(screen.getByRole('button')).toHaveTextContent('Request Access');
+ });
+ });
+
+ describe('with a public group', () => {
+ beforeEach(() => {
+ group = group.set('locked', false);
+ });
+
+ it('should render the Join Group button', () => {
+ render();
+
+ expect(screen.getByRole('button')).toHaveTextContent('Join Group');
+ });
+ });
+ });
+
+ describe('with no group relationship member', () => {
+ beforeEach(() => {
+ group = normalizeGroup({
+ relationship: normalizeGroupRelationship({
+ member: null,
+ }),
+ });
+ });
+
+ describe('with a private group', () => {
+ beforeEach(() => {
+ group = group.set('locked', true);
+ });
+
+ it('should render the Request Access button', () => {
+ render();
+
+ expect(screen.getByRole('button')).toHaveTextContent('Request Access');
+ });
+ });
+
+ describe('with a public group', () => {
+ beforeEach(() => {
+ group = group.set('locked', false);
+ });
+
+ it('should render the Join Group button', () => {
+ render();
+
+ expect(screen.getByRole('button')).toHaveTextContent('Join Group');
+ });
+ });
+ });
+
+ describe('when the user has requested to join', () => {
+ beforeEach(() => {
+ group = normalizeGroup({
+ relationship: normalizeGroupRelationship({
+ requested: true,
+ member: true,
+ }),
+ });
+ });
+
+ it('should render the Cancel Request button', () => {
+ render();
+
+ expect(screen.getByRole('button')).toHaveTextContent('Cancel Request');
+ });
+ });
+
+ describe('when the user is an Admin', () => {
+ beforeEach(() => {
+ group = normalizeGroup({
+ relationship: normalizeGroupRelationship({
+ requested: false,
+ member: true,
+ role: 'admin',
+ }),
+ });
+ });
+
+ it('should render the Manage Group button', () => {
+ render();
+
+ expect(screen.getByRole('button')).toHaveTextContent('Manage Group');
+ });
+ });
+
+ describe('when the user is just a member', () => {
+ beforeEach(() => {
+ group = normalizeGroup({
+ relationship: normalizeGroupRelationship({
+ requested: false,
+ member: true,
+ role: 'user',
+ }),
+ });
+ });
+
+ it('should render the Leave Group button', () => {
+ render();
+
+ expect(screen.getByRole('button')).toHaveTextContent('Leave Group');
+ });
+ });
+});
\ No newline at end of file
diff --git a/app/soapbox/features/group/components/__tests__/group-member-count.test.tsx b/app/soapbox/features/group/components/__tests__/group-member-count.test.tsx
new file mode 100644
index 000000000..86e9baac8
--- /dev/null
+++ b/app/soapbox/features/group/components/__tests__/group-member-count.test.tsx
@@ -0,0 +1,69 @@
+import React from 'react';
+
+import { render, screen } from 'soapbox/jest/test-helpers';
+import { normalizeGroup } from 'soapbox/normalizers';
+import { Group } from 'soapbox/types/entities';
+
+import GroupMemberCount from '../group-member-count';
+
+let group: Group;
+
+describe('', () => {
+ describe('without support for "members_count"', () => {
+ beforeEach(() => {
+ group = normalizeGroup({
+ members_count: undefined,
+ });
+ });
+
+ it('should return null', () => {
+ render();
+
+ expect(screen.queryAllByTestId('group-member-count')).toHaveLength(0);
+ });
+ });
+
+ describe('with support for "members_count"', () => {
+ describe('with 1 member', () => {
+ beforeEach(() => {
+ group = normalizeGroup({
+ members_count: 1,
+ });
+ });
+
+ it('should render correctly', () => {
+ render();
+
+ expect(screen.getByTestId('group-member-count').textContent).toEqual('1 member');
+ });
+ });
+
+ describe('with 2 members', () => {
+ beforeEach(() => {
+ group = normalizeGroup({
+ members_count: 2,
+ });
+ });
+
+ it('should render correctly', () => {
+ render();
+
+ expect(screen.getByTestId('group-member-count').textContent).toEqual('2 members');
+ });
+ });
+
+ describe('with 1000 members', () => {
+ beforeEach(() => {
+ group = normalizeGroup({
+ members_count: 1000,
+ });
+ });
+
+ it('should render correctly', () => {
+ render();
+
+ expect(screen.getByTestId('group-member-count').textContent).toEqual('1k members');
+ });
+ });
+ });
+});
\ No newline at end of file
diff --git a/app/soapbox/features/group/components/__tests__/group-privacy.test.tsx b/app/soapbox/features/group/components/__tests__/group-privacy.test.tsx
new file mode 100644
index 000000000..72e4454e7
--- /dev/null
+++ b/app/soapbox/features/group/components/__tests__/group-privacy.test.tsx
@@ -0,0 +1,39 @@
+import React from 'react';
+
+import { render, screen } from 'soapbox/jest/test-helpers';
+import { normalizeGroup } from 'soapbox/normalizers';
+import { Group } from 'soapbox/types/entities';
+
+import GroupPrivacy from '../group-privacy';
+
+let group: Group;
+
+describe('', () => {
+ describe('with a Private group', () => {
+ beforeEach(() => {
+ group = normalizeGroup({
+ locked: true,
+ });
+ });
+
+ it('should render the correct text', () => {
+ render();
+
+ expect(screen.getByTestId('group-privacy')).toHaveTextContent('Private');
+ });
+ });
+
+ describe('with a Public group', () => {
+ beforeEach(() => {
+ group = normalizeGroup({
+ locked: false,
+ });
+ });
+
+ it('should render the correct text', () => {
+ render();
+
+ expect(screen.getByTestId('group-privacy')).toHaveTextContent('Public');
+ });
+ });
+});
\ No newline at end of file
diff --git a/app/soapbox/features/group/components/group-action-button.tsx b/app/soapbox/features/group/components/group-action-button.tsx
new file mode 100644
index 000000000..53f27f709
--- /dev/null
+++ b/app/soapbox/features/group/components/group-action-button.tsx
@@ -0,0 +1,83 @@
+import React from 'react';
+import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
+
+import { joinGroup, leaveGroup } from 'soapbox/actions/groups';
+import { openModal } from 'soapbox/actions/modals';
+import { Button } from 'soapbox/components/ui';
+import { useAppDispatch } from 'soapbox/hooks';
+import { Group } from 'soapbox/types/entities';
+
+interface IGroupActionButton {
+ group: Group
+}
+
+const messages = defineMessages({
+ confirmationHeading: { id: 'confirmations.leave_group.heading', defaultMessage: 'Leave group' },
+ confirmationMessage: { id: 'confirmations.leave_group.message', defaultMessage: 'You are about to leave the group. Do you want to continue?' },
+ confirmationConfirm: { id: 'confirmations.leave_group.confirm', defaultMessage: 'Leave' },
+});
+
+const GroupActionButton = ({ group }: IGroupActionButton) => {
+ const dispatch = useAppDispatch();
+ const intl = useIntl();
+
+ const isNonMember = !group.relationship || !group.relationship.member;
+ const isRequested = group.relationship?.requested;
+ const isAdmin = group.relationship?.role === 'admin';
+
+ const onJoinGroup = () => dispatch(joinGroup(group.id));
+
+ const onLeaveGroup = () =>
+ dispatch(openModal('CONFIRM', {
+ heading: intl.formatMessage(messages.confirmationHeading),
+ message: intl.formatMessage(messages.confirmationMessage),
+ confirm: intl.formatMessage(messages.confirmationConfirm),
+ onConfirm: () => dispatch(leaveGroup(group.id)),
+ }));
+
+ if (isNonMember) {
+ return (
+
+ );
+ }
+
+ if (isRequested) {
+ return (
+
+ );
+ }
+
+ if (isAdmin) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+};
+
+export default GroupActionButton;
\ No newline at end of file
diff --git a/app/soapbox/features/group/components/group-header.tsx b/app/soapbox/features/group/components/group-header.tsx
index 0e9eb6548..7b532ddcc 100644
--- a/app/soapbox/features/group/components/group-header.tsx
+++ b/app/soapbox/features/group/components/group-header.tsx
@@ -1,22 +1,23 @@
import { List as ImmutableList } from 'immutable';
import React from 'react';
-import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
+import { defineMessages, useIntl } from 'react-intl';
-import { joinGroup, leaveGroup } from 'soapbox/actions/groups';
import { openModal } from 'soapbox/actions/modals';
import StillImage from 'soapbox/components/still-image';
-import { Avatar, Button, HStack, Icon, Stack, Text } from 'soapbox/components/ui';
+import { Avatar, HStack, Stack, Text } from 'soapbox/components/ui';
import { useAppDispatch } from 'soapbox/hooks';
import { normalizeAttachment } from 'soapbox/normalizers';
import { isDefaultHeader } from 'soapbox/utils/accounts';
+import GroupActionButton from './group-action-button';
+import GroupMemberCount from './group-member-count';
+import GroupPrivacy from './group-privacy';
+import GroupRelationship from './group-relationship';
+
import type { Group } from 'soapbox/types/entities';
const messages = defineMessages({
header: { id: 'group.header.alt', defaultMessage: 'Group header' },
- confirmationHeading: { id: 'confirmations.leave_group.heading', defaultMessage: 'Leave group' },
- confirmationMessage: { id: 'confirmations.leave_group.message', defaultMessage: 'You are about to leave the group. Do you want to continue?' },
- confirmationConfirm: { id: 'confirmations.leave_group.confirm', defaultMessage: 'Leave' },
});
interface IGroupHeader {
@@ -47,16 +48,6 @@ const GroupHeader: React.FC = ({ group }) => {
);
}
- const onJoinGroup = () => dispatch(joinGroup(group.id));
-
- const onLeaveGroup = () =>
- dispatch(openModal('CONFIRM', {
- heading: intl.formatMessage(messages.confirmationHeading),
- message: intl.formatMessage(messages.confirmationMessage),
- confirm: intl.formatMessage(messages.confirmationConfirm),
- onConfirm: () => dispatch(leaveGroup(group.id)),
- }));
-
const onAvatarClick = () => {
const avatar = normalizeAttachment({
type: 'image',
@@ -95,6 +86,7 @@ const GroupHeader: React.FC = ({ group }) => {
);
@@ -110,95 +102,40 @@ const GroupHeader: React.FC = ({ group }) => {
return header;
};
- const makeActionButton = () => {
- if (!group.relationship || !group.relationship.member) {
- return (
-
- );
- }
-
- if (group.relationship.requested) {
- return (
-
- );
- }
-
- if (group.relationship?.role === 'admin') {
- return (
-
- );
- }
-
- return (
-
- );
- };
-
- const actionButton = makeActionButton();
-
return (
-
- {renderHeader()}
-
+ {renderHeader()}
+
-
-
-
- {group.relationship?.role === 'admin' ? (
-
-
-
-
- ) : group.relationship?.role === 'moderator' && (
-
-
-
-
- )}
- {group.locked ? (
-
-
-
-
- ) : (
-
-
-
-
- )}
-
-
- {actionButton}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
);
diff --git a/app/soapbox/features/group/components/group-member-count.tsx b/app/soapbox/features/group/components/group-member-count.tsx
new file mode 100644
index 000000000..e4dd33e54
--- /dev/null
+++ b/app/soapbox/features/group/components/group-member-count.tsx
@@ -0,0 +1,32 @@
+import React from 'react';
+import { FormattedMessage } from 'react-intl';
+
+import { Text } from 'soapbox/components/ui';
+import { Group } from 'soapbox/types/entities';
+import { shortNumberFormat } from 'soapbox/utils/numbers';
+
+interface IGroupMemberCount {
+ group: Group
+}
+
+const GroupMemberCount = ({ group }: IGroupMemberCount) => {
+ if (typeof group.members_count === 'undefined') {
+ return null;
+ }
+
+ return (
+
+ {shortNumberFormat(group.members_count)}
+ {' '}
+
+
+ );
+};
+
+export default GroupMemberCount;
\ No newline at end of file
diff --git a/app/soapbox/features/group/components/group-privacy.tsx b/app/soapbox/features/group/components/group-privacy.tsx
new file mode 100644
index 000000000..fdbbe2977
--- /dev/null
+++ b/app/soapbox/features/group/components/group-privacy.tsx
@@ -0,0 +1,32 @@
+import React from 'react';
+import { FormattedMessage } from 'react-intl';
+
+import { HStack, Icon, Text } from 'soapbox/components/ui';
+import { Group } from 'soapbox/types/entities';
+
+interface IGroupPolicy {
+ group: Group
+}
+
+const GroupPrivacy = ({ group }: IGroupPolicy) => (
+
+
+
+
+ {group.locked ? (
+
+ ) : (
+
+ )}
+
+
+);
+
+export default GroupPrivacy;
\ No newline at end of file
diff --git a/app/soapbox/features/group/components/group-relationship.tsx b/app/soapbox/features/group/components/group-relationship.tsx
new file mode 100644
index 000000000..6b79ecda5
--- /dev/null
+++ b/app/soapbox/features/group/components/group-relationship.tsx
@@ -0,0 +1,39 @@
+import React from 'react';
+import { FormattedMessage } from 'react-intl';
+
+import { HStack, Icon, Text } from 'soapbox/components/ui';
+import { Group } from 'soapbox/types/entities';
+
+interface IGroupRelationship {
+ group: Group
+}
+
+const GroupRelationship = ({ group }: IGroupRelationship) => {
+ const isAdmin = group.relationship?.role === 'admin';
+ const isModerator = group.relationship?.role === 'moderator';
+
+ if (!isAdmin || !isModerator) {
+ return null;
+ }
+
+ return (
+
+
+
+
+ {isAdmin
+ ?
+ : }
+
+
+ );
+};
+
+export default GroupRelationship;
\ No newline at end of file
diff --git a/app/soapbox/features/group/group-timeline.tsx b/app/soapbox/features/group/group-timeline.tsx
index f4cf2f574..f80343c1e 100644
--- a/app/soapbox/features/group/group-timeline.tsx
+++ b/app/soapbox/features/group/group-timeline.tsx
@@ -3,7 +3,6 @@ import { FormattedMessage } from 'react-intl';
import { Link } from 'react-router-dom';
import { groupCompose } from 'soapbox/actions/compose';
-import { fetchGroup } from 'soapbox/actions/groups';
import { connectGroupStream } from 'soapbox/actions/streaming';
import { expandGroupTimeline } from 'soapbox/actions/timelines';
import { Avatar, HStack, Stack } from 'soapbox/components/ui';
@@ -31,7 +30,6 @@ const GroupTimeline: React.FC = (props) => {
};
useEffect(() => {
- dispatch(fetchGroup(groupId));
dispatch(expandGroupTimeline(groupId));
dispatch(groupCompose(`group:${groupId}`, groupId));
diff --git a/app/soapbox/features/groups/components/discover/group.tsx b/app/soapbox/features/groups/components/discover/group.tsx
index 156605e71..a596f95f2 100644
--- a/app/soapbox/features/groups/components/discover/group.tsx
+++ b/app/soapbox/features/groups/components/discover/group.tsx
@@ -1,9 +1,11 @@
import React, { forwardRef } from 'react';
import { FormattedMessage } from 'react-intl';
+import { Link } from 'react-router-dom';
-import { Avatar, Button, HStack, Icon, Stack, Text } from 'soapbox/components/ui';
+import { Avatar, Button, HStack, Stack, Text } from 'soapbox/components/ui';
+import GroupMemberCount from 'soapbox/features/group/components/group-member-count';
+import GroupPrivacy from 'soapbox/features/group/components/group-privacy';
import { Group as GroupEntity } from 'soapbox/types/entities';
-import { shortNumberFormat } from 'soapbox/utils/numbers';
interface IGroup {
group: GroupEntity
@@ -21,75 +23,56 @@ const Group = forwardRef((props: IGroup, ref: React.ForwardedRef
width,
}}
>
-
- {group.header && (
-
- )}
+
+
+ {group.header && (
+
+ )}
-
-
-
-
-
+
-
-
+
- {typeof group.members_count === 'undefined' ? (
-
- {group.locked ? (
-
- ) : (
-
- )}
-
- ) : (
-
- {shortNumberFormat(group.members_count)}
- {' '}
-
-
- )}
-
+
+
+ •
+
+
+
-
-
-
+
+
+
);
diff --git a/app/soapbox/features/groups/components/discover/search/results.tsx b/app/soapbox/features/groups/components/discover/search/results.tsx
index 59af5aebc..cfbc74039 100644
--- a/app/soapbox/features/groups/components/discover/search/results.tsx
+++ b/app/soapbox/features/groups/components/discover/search/results.tsx
@@ -96,7 +96,7 @@ export default (props: Props) => {
diff --git a/app/soapbox/locales/en.json b/app/soapbox/locales/en.json
index aa3e0fc34..35c111786 100644
--- a/app/soapbox/locales/en.json
+++ b/app/soapbox/locales/en.json
@@ -745,7 +745,7 @@
"gdpr.title": "{siteTitle} uses cookies",
"getting_started.open_source_notice": "{code_name} is open source software. You can contribute or report issues at {code_link} (v{code_version}).",
"group.admin_subheading": "Group administrators",
- "group.cancel_request": "Cancel request",
+ "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",
@@ -763,13 +763,13 @@
"group.group_mod_unblock": "Unblock",
"group.group_mod_unblock.success": "Unblocked @{name} from group",
"group.header.alt": "Group header",
- "group.join.private": "Request to Join",
+ "group.join.private": "Request Access",
"group.join.public": "Join Group",
"group.join.request_success": "Requested to join the group",
"group.join.success": "Joined the group",
- "group.leave": "Leave group",
+ "group.leave": "Leave Group",
"group.leave.success": "Left the group",
- "group.manage": "Manage group",
+ "group.manage": "Manage Group",
"group.moderator_subheading": "Group moderators",
"group.privacy.locked": "Private",
"group.privacy.public": "Public",
diff --git a/app/soapbox/pages/group-page.tsx b/app/soapbox/pages/group-page.tsx
index d48caafd0..92518a55d 100644
--- a/app/soapbox/pages/group-page.tsx
+++ b/app/soapbox/pages/group-page.tsx
@@ -1,10 +1,8 @@
-import React, { useCallback, useEffect } from 'react';
+import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { useRouteMatch } from 'react-router-dom';
-import { fetchGroup } from 'soapbox/actions/groups';
-import MissingIndicator from 'soapbox/components/missing-indicator';
-import { Column, Layout } from 'soapbox/components/ui';
+import { Column, Icon, Layout, Stack, Text } from 'soapbox/components/ui';
import GroupHeader from 'soapbox/features/group/components/group-header';
import LinkFooter from 'soapbox/features/ui/components/link-footer';
import BundleContainer from 'soapbox/features/ui/containers/bundle-container';
@@ -13,8 +11,8 @@ import {
GroupMediaPanel,
SignUpPanel,
} from 'soapbox/features/ui/util/async-components';
-import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
-import { makeGetGroup } from 'soapbox/selectors';
+import { useOwnAccount } from 'soapbox/hooks';
+import { useGroup } from 'soapbox/queries/groups';
import { Tabs } from '../components/ui';
@@ -34,23 +32,20 @@ interface IGroupPage {
const GroupPage: React.FC = ({ params, children }) => {
const intl = useIntl();
const match = useRouteMatch();
- const dispatch = useAppDispatch();
+ const me = useOwnAccount();
const id = params?.id || '';
- const getGroup = useCallback(makeGetGroup(), []);
- const group = useAppSelector(state => getGroup(state, id));
- const me = useAppSelector(state => state.me);
+ const { group } = useGroup(id);
- useEffect(() => {
- dispatch(fetchGroup(id));
- }, [id]);
+ const isNonMember = !group?.relationship || !group.relationship.member;
+ const isPrivate = group?.locked;
- if ((group as any) === false) {
- return (
-
- );
- }
+ // if ((group as any) === false) {
+ // return (
+ //
+ // );
+ // }
const items = [
{
@@ -76,7 +71,18 @@ const GroupPage: React.FC = ({ params, children }) => {
activeItem={match.path}
/>
- {children}
+ {(isNonMember && isPrivate) ? (
+
+
+
+
+
+
+ Content is only visible to group members
+
+
+
+ ) : children}
{!me && (
diff --git a/app/soapbox/queries/groups.ts b/app/soapbox/queries/groups.ts
index 612c60b43..5fb8ed2dc 100644
--- a/app/soapbox/queries/groups.ts
+++ b/app/soapbox/queries/groups.ts
@@ -9,6 +9,7 @@ import { Group } from 'soapbox/types/entities';
import { flattenPages, PaginatedResult } from 'soapbox/utils/queries';
const GroupKeys = {
+ group: (id: string) => ['groups', 'group', id] as const,
myGroups: (userId: string) => ['groups', userId] as const,
popularGroups: ['groups', 'popular'] as const,
suggestedGroups: ['groups', 'suggested'] as const,
@@ -70,7 +71,7 @@ const usePopularGroups = () => {
const features = useFeatures();
const getQuery = async () => {
- const { data } = await api.get('/api/mock/groups'); // '/api/v1/truth/trends/groups'
+ const { data } = await api.get('/api/v1/groups/search?q=group'); // '/api/v1/truth/trends/groups'
const result = data.map(normalizeGroup);
return result;
@@ -109,4 +110,23 @@ const useSuggestedGroups = () => {
};
};
-export { useGroups, usePopularGroups, useSuggestedGroups };
+const useGroup = (id: string) => {
+ const api = useApi();
+ const features = useFeatures();
+
+ const getGroup = async () => {
+ const { data } = await api.get(`/api/v1/groups/${id}`);
+ return normalizeGroup(data);
+ };
+
+ const queryInfo = useQuery(GroupKeys.group(id), getGroup, {
+ enabled: features.groups && !!id,
+ });
+
+ return {
+ ...queryInfo,
+ group: queryInfo.data,
+ };
+};
+
+export { useGroups, useGroup, usePopularGroups, useSuggestedGroups };