From 83532aedbacba539bb996b3fc7e7e393b643f8ad Mon Sep 17 00:00:00 2001 From: Chewbacca Date: Tue, 7 Mar 2023 12:05:24 -0500 Subject: [PATCH 1/3] Update blankslate to allow Group Creation --- app/soapbox/features/groups/index.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/app/soapbox/features/groups/index.tsx b/app/soapbox/features/groups/index.tsx index 77b72552a..9fd95640e 100644 --- a/app/soapbox/features/groups/index.tsx +++ b/app/soapbox/features/groups/index.tsx @@ -57,7 +57,6 @@ const Groups: React.FC = () => { ); - return ( {canCreateGroup && ( From f21f72461af822293cd45f9cef453099359dc3bc Mon Sep 17 00:00:00 2001 From: Chewbacca Date: Wed, 8 Mar 2023 13:22:10 -0500 Subject: [PATCH 2/3] Add support for pending Group Requests --- app/soapbox/components/group-card.tsx | 41 +++---- .../__tests__/pending-requests.test.tsx | 84 ++++++++++++++ .../__tests__/pending-group-rows.test.tsx | 103 ++++++++++++++++++ .../{group.tsx => group-grid-item.tsx} | 4 +- .../components/discover/group-list-item.tsx | 80 ++++++++++++++ .../components/discover/popular-groups.tsx | 4 +- .../components/discover/search/results.tsx | 67 ++---------- .../components/discover/suggested-groups.tsx | 4 +- .../groups/components/pending-groups-row.tsx | 49 +++++++++ app/soapbox/features/groups/index.tsx | 5 +- .../features/groups/pending-requests.tsx | 68 ++++++++++++ .../panels/suggested-groups-panel.tsx | 33 ++++++ app/soapbox/features/ui/index.tsx | 3 + .../features/ui/util/async-components.ts | 8 ++ app/soapbox/pages/groups-page.tsx | 1 + app/soapbox/pages/groups-pending-page.tsx | 33 ++++++ app/soapbox/queries/groups.ts | 65 ++++++++++- app/soapbox/utils/features.ts | 5 + 18 files changed, 560 insertions(+), 97 deletions(-) create mode 100644 app/soapbox/features/groups/__tests__/pending-requests.test.tsx create mode 100644 app/soapbox/features/groups/components/__tests__/pending-group-rows.test.tsx rename app/soapbox/features/groups/components/discover/{group.tsx => group-grid-item.tsx} (94%) create mode 100644 app/soapbox/features/groups/components/discover/group-list-item.tsx create mode 100644 app/soapbox/features/groups/components/pending-groups-row.tsx create mode 100644 app/soapbox/features/groups/pending-requests.tsx create mode 100644 app/soapbox/features/ui/components/panels/suggested-groups-panel.tsx create mode 100644 app/soapbox/pages/groups-pending-page.tsx diff --git a/app/soapbox/components/group-card.tsx b/app/soapbox/components/group-card.tsx index a977e1ef0..faf9d3514 100644 --- a/app/soapbox/components/group-card.tsx +++ b/app/soapbox/components/group-card.tsx @@ -1,7 +1,11 @@ import React from 'react'; -import { FormattedMessage, defineMessages, useIntl } from 'react-intl'; +import { defineMessages, useIntl } from 'react-intl'; -import { Avatar, HStack, Icon, Stack, Text } from './ui'; +import GroupMemberCount from 'soapbox/features/group/components/group-member-count'; +import GroupPrivacy from 'soapbox/features/group/components/group-privacy'; +import GroupRelationship from 'soapbox/features/group/components/group-relationship'; + +import { Avatar, HStack, Stack, Text } from './ui'; import type { Group as GroupEntity } from 'soapbox/types/entities'; @@ -17,7 +21,10 @@ const GroupCard: React.FC = ({ group }) => { const intl = useIntl(); return ( - + {/* Group Cover Image */} {group.header && ( @@ -37,30 +44,10 @@ const GroupCard: React.FC = ({ group }) => { - - {group.relationship?.role === 'admin' ? ( - - - - - ) : group.relationship?.role === 'moderator' && ( - - - - - )} - - {group.locked ? ( - - - - - ) : ( - - - - - )} + + + + diff --git a/app/soapbox/features/groups/__tests__/pending-requests.test.tsx b/app/soapbox/features/groups/__tests__/pending-requests.test.tsx new file mode 100644 index 000000000..fbc7aba3a --- /dev/null +++ b/app/soapbox/features/groups/__tests__/pending-requests.test.tsx @@ -0,0 +1,84 @@ +import { Map as ImmutableMap } from 'immutable'; +import React from 'react'; +import { VirtuosoMockContext } from 'react-virtuoso'; + +import { __stub } from 'soapbox/api'; +import { render, screen, waitFor } from 'soapbox/jest/test-helpers'; +import { normalizeAccount, normalizeGroup, normalizeGroupRelationship, normalizeInstance } from 'soapbox/normalizers'; + +import PendingRequests from '../pending-requests'; + +const userId = '1'; +const store: any = { + me: userId, + accounts: ImmutableMap({ + [userId]: normalizeAccount({ + id: userId, + acct: 'justin-username', + display_name: 'Justin L', + avatar: 'test.jpg', + chats_onboarded: false, + }), + }), + instance: normalizeInstance({ + version: '3.4.1 (compatible; TruthSocial 1.0.0)', + software: 'TRUTHSOCIAL', + }), +}; + +const renderApp = () => ( + render( + + + , + undefined, + store, + ) +); + +describe('', () => { + describe('without pending group requests', () => { + beforeEach(() => { + __stub((mock) => { + mock.onGet('/api/v1/groups?pending=true').reply(200, []); + }); + }); + + it('should render the blankslate', async () => { + renderApp(); + + await waitFor(() => { + expect(screen.getByTestId('pending-requests-blankslate')).toBeInTheDocument(); + expect(screen.queryAllByTestId('group-card')).toHaveLength(0); + }); + }); + }); + + describe('with pending group requests', () => { + beforeEach(() => { + __stub((mock) => { + mock.onGet('/api/v1/groups').reply(200, [ + normalizeGroup({ + display_name: 'Group', + id: '1', + }), + ]); + + mock.onGet('/api/v1/groups/relationships?id[]=1').reply(200, [ + normalizeGroupRelationship({ + id: '1', + }), + ]); + }); + }); + + it('should render the groups', async () => { + renderApp(); + + await waitFor(() => { + expect(screen.queryAllByTestId('group-card')).toHaveLength(1); + expect(screen.queryAllByTestId('pending-requests-blankslate')).toHaveLength(0); + }); + }); + }); +}); \ No newline at end of file diff --git a/app/soapbox/features/groups/components/__tests__/pending-group-rows.test.tsx b/app/soapbox/features/groups/components/__tests__/pending-group-rows.test.tsx new file mode 100644 index 000000000..259c0f44e --- /dev/null +++ b/app/soapbox/features/groups/components/__tests__/pending-group-rows.test.tsx @@ -0,0 +1,103 @@ +import { Map as ImmutableMap } from 'immutable'; +import React from 'react'; +import { VirtuosoMockContext } from 'react-virtuoso'; + +import { __stub } from 'soapbox/api'; +import { render, screen, waitFor } from 'soapbox/jest/test-helpers'; +import { normalizeAccount, normalizeGroup, normalizeGroupRelationship, normalizeInstance } from 'soapbox/normalizers'; + +import PendingGroupsRow from '../pending-groups-row'; + +const userId = '1'; +let store: any = { + me: userId, + accounts: ImmutableMap({ + [userId]: normalizeAccount({ + id: userId, + acct: 'justin-username', + display_name: 'Justin L', + avatar: 'test.jpg', + chats_onboarded: false, + }), + }), +}; + +const renderApp = (store: any) => ( + render( + + + , + undefined, + store, + ) +); + +describe('', () => { + describe('without the feature', () => { + beforeEach(() => { + store = { + ...store, + instance: normalizeInstance({ + version: '2.7.2 (compatible; Pleroma 2.3.0)', + }), + }; + }); + + it('should not render', () => { + renderApp(store); + expect(screen.queryAllByTestId('pending-groups-row')).toHaveLength(0); + }); + }); + + describe('with the feature', () => { + beforeEach(() => { + store = { + ...store, + instance: normalizeInstance({ + version: '3.4.1 (compatible; TruthSocial 1.0.0)', + software: 'TRUTHSOCIAL', + }), + }; + }); + + describe('without pending group requests', () => { + beforeEach(() => { + __stub((mock) => { + mock.onGet('/api/v1/groups?pending=true').reply(200, []); + }); + }); + + it('should not render', () => { + renderApp(store); + expect(screen.queryAllByTestId('pending-groups-row')).toHaveLength(0); + }); + }); + + describe('with pending group requests', () => { + beforeEach(() => { + __stub((mock) => { + mock.onGet('/api/v1/groups').reply(200, [ + normalizeGroup({ + display_name: 'Group', + id: '1', + }), + ]); + + mock.onGet('/api/v1/groups/relationships?id[]=1').reply(200, [ + normalizeGroupRelationship({ + id: '1', + }), + ]); + }); + }); + + it('should render the row', async () => { + renderApp(store); + + await waitFor(() => { + expect(screen.queryAllByTestId('pending-groups-row')).toHaveLength(1); + }); + }); + }); + }); +}); \ No newline at end of file diff --git a/app/soapbox/features/groups/components/discover/group.tsx b/app/soapbox/features/groups/components/discover/group-grid-item.tsx similarity index 94% rename from app/soapbox/features/groups/components/discover/group.tsx rename to app/soapbox/features/groups/components/discover/group-grid-item.tsx index a596f95f2..4faaa20c3 100644 --- a/app/soapbox/features/groups/components/discover/group.tsx +++ b/app/soapbox/features/groups/components/discover/group-grid-item.tsx @@ -12,7 +12,7 @@ interface IGroup { width?: number } -const Group = forwardRef((props: IGroup, ref: React.ForwardedRef) => { +const GroupGridItem = forwardRef((props: IGroup, ref: React.ForwardedRef) => { const { group, width = 'auto' } = props; return ( @@ -78,4 +78,4 @@ const Group = forwardRef((props: IGroup, ref: React.ForwardedRef ); }); -export default Group; \ No newline at end of file +export default GroupGridItem; \ No newline at end of file diff --git a/app/soapbox/features/groups/components/discover/group-list-item.tsx b/app/soapbox/features/groups/components/discover/group-list-item.tsx new file mode 100644 index 000000000..fbd67b99a --- /dev/null +++ b/app/soapbox/features/groups/components/discover/group-list-item.tsx @@ -0,0 +1,80 @@ +import React from 'react'; +import { FormattedMessage } from 'react-intl'; + +import { Avatar, Button, HStack, Icon, Stack, Text } from 'soapbox/components/ui'; +import { Group as GroupEntity } from 'soapbox/types/entities'; +import { shortNumberFormat } from 'soapbox/utils/numbers'; + +interface IGroup { + group: GroupEntity + withJoinAction?: boolean +} + +const GroupListItem = (props: IGroup) => { + const { group, withJoinAction = true } = props; + + return ( + + + + + + + + + + + + {group.locked ? ( + + ) : ( + + )} + + + {typeof group.members_count !== 'undefined' && ( + <> + + + {shortNumberFormat(group.members_count)} + {' '} + + + + )} + + + + + {withJoinAction && ( + + )} + + ); +}; + +export default GroupListItem; diff --git a/app/soapbox/features/groups/components/discover/popular-groups.tsx b/app/soapbox/features/groups/components/discover/popular-groups.tsx index 5b30905f2..63b9067e0 100644 --- a/app/soapbox/features/groups/components/discover/popular-groups.tsx +++ b/app/soapbox/features/groups/components/discover/popular-groups.tsx @@ -5,7 +5,7 @@ import { Carousel, Stack, Text } from 'soapbox/components/ui'; import PlaceholderGroupDiscover from 'soapbox/features/placeholder/components/placeholder-group-discover'; import { usePopularGroups } from 'soapbox/queries/groups'; -import Group from './group'; +import GroupGridItem from './group-grid-item'; const PopularGroups = () => { const { groups, isFetching, isFetched, isError } = usePopularGroups(); @@ -49,7 +49,7 @@ const PopularGroups = () => { )) ) : ( groups.map((group) => ( - @@ -38,73 +38,20 @@ export default (props: Props) => { }; const renderGroupList = useCallback((group: Group, index: number) => ( - - - - - - - - - - - - {group.locked ? ( - - ) : ( - - )} - - - {typeof group.members_count !== 'undefined' && ( - <> - - - {shortNumberFormat(group.members_count)} - {' '} - - - - )} - - - - - - + + ), []); const renderGroupGrid = useCallback((group: Group, index: number) => (
- +
), []); diff --git a/app/soapbox/features/groups/components/discover/suggested-groups.tsx b/app/soapbox/features/groups/components/discover/suggested-groups.tsx index 5c70b6b16..1372f2f43 100644 --- a/app/soapbox/features/groups/components/discover/suggested-groups.tsx +++ b/app/soapbox/features/groups/components/discover/suggested-groups.tsx @@ -5,7 +5,7 @@ import { Carousel, Stack, Text } from 'soapbox/components/ui'; import PlaceholderGroupDiscover from 'soapbox/features/placeholder/components/placeholder-group-discover'; import { useSuggestedGroups } from 'soapbox/queries/groups'; -import Group from './group'; +import GroupGridItem from './group-grid-item'; const SuggestedGroups = () => { const { groups, isFetching, isFetched, isError } = useSuggestedGroups(); @@ -49,7 +49,7 @@ const SuggestedGroups = () => { )) ) : ( groups.map((group) => ( - { + const features = useFeatures(); + + const { groups, isFetching } = usePendingGroups(); + + if (!features.groupsPending || isFetching || groups.length === 0) { + return null; + } + + return ( + <> + + + +
+ +
+ + + + +
+ + +
+ + + + + ); +}; \ No newline at end of file diff --git a/app/soapbox/features/groups/index.tsx b/app/soapbox/features/groups/index.tsx index 9fd95640e..30319160b 100644 --- a/app/soapbox/features/groups/index.tsx +++ b/app/soapbox/features/groups/index.tsx @@ -11,6 +11,7 @@ import { PERMISSION_CREATE_GROUPS, hasPermission } from 'soapbox/utils/permissio import PlaceholderGroupCard from '../placeholder/components/placeholder-group-card'; +import PendingGroupsRow from './components/pending-groups-row'; import TabBar, { TabItems } from './components/tab-bar'; import type { Group as GroupEntity } from 'soapbox/types/entities'; @@ -75,11 +76,13 @@ const Groups: React.FC = () => { )} + + { + const intl = useIntl(); + + const { groups, isLoading } = usePendingGroups(); + + + const renderBlankslate = () => ( + + + + + + + + + + + + ); + + return ( + + + {groups.map((group) => ( + + + + ))} + + + ); +}; \ No newline at end of file diff --git a/app/soapbox/features/ui/components/panels/suggested-groups-panel.tsx b/app/soapbox/features/ui/components/panels/suggested-groups-panel.tsx new file mode 100644 index 000000000..a3c5a44d5 --- /dev/null +++ b/app/soapbox/features/ui/components/panels/suggested-groups-panel.tsx @@ -0,0 +1,33 @@ +import React from 'react'; + +import { Widget } from 'soapbox/components/ui'; +import GroupListItem from 'soapbox/features/groups/components/discover/group-list-item'; +import PlaceholderGroupSearch from 'soapbox/features/placeholder/components/placeholder-group-search'; +import { useSuggestedGroups } from 'soapbox/queries/groups'; + +const SuggestedGroupsPanel = () => { + const { groups, isFetching, isFetched, isError } = useSuggestedGroups(); + const isEmpty = (isFetched && groups.length === 0) || isError; + + if (isEmpty) { + return null; + } + + return ( + + {isFetching ? ( + new Array(3).fill(0).map((_, idx) => ( + + )) + ) : ( + groups.slice(0, 3).map((group) => ( + + )) + )} + + ); +}; + +export default SuggestedGroupsPanel; diff --git a/app/soapbox/features/ui/index.tsx b/app/soapbox/features/ui/index.tsx index bf23373da..38dc88581 100644 --- a/app/soapbox/features/ui/index.tsx +++ b/app/soapbox/features/ui/index.tsx @@ -32,6 +32,7 @@ import EventPage from 'soapbox/pages/event-page'; import EventsPage from 'soapbox/pages/events-page'; import GroupPage from 'soapbox/pages/group-page'; import GroupsPage from 'soapbox/pages/groups-page'; +import GroupsPendingPage from 'soapbox/pages/groups-pending-page'; import HomePage from 'soapbox/pages/home-page'; import ProfilePage from 'soapbox/pages/profile-page'; import RemoteInstancePage from 'soapbox/pages/remote-instance-page'; @@ -117,6 +118,7 @@ import { Events, Groups, GroupsDiscover, + PendingGroupRequests, GroupMembers, GroupTimeline, ManageGroup, @@ -287,6 +289,7 @@ const SwitchingColumnsArea: React.FC = ({ children }) => {features.groups && } {features.groupsDiscovery && } + {features.groupsPending && } {features.groups && } {features.groups && } {features.groups && } diff --git a/app/soapbox/features/ui/util/async-components.ts b/app/soapbox/features/ui/util/async-components.ts index 88e55b2be..ec8384884 100644 --- a/app/soapbox/features/ui/util/async-components.ts +++ b/app/soapbox/features/ui/util/async-components.ts @@ -550,6 +550,10 @@ export function GroupsDiscover() { return import(/* webpackChunkName: "features/groups/discover" */'../../groups/discover'); } +export function PendingGroupRequests() { + return import(/* webpackChunkName: "features/groups/discover" */'../../groups/pending-requests'); +} + export function GroupMembers() { return import(/* webpackChunkName: "features/groups" */'../../group/group-members'); } @@ -578,6 +582,10 @@ export function NewGroupPanel() { return import(/* webpackChunkName: "features/groups" */'../components/panels/new-group-panel'); } +export function SuggestedGroupsPanel() { + return import(/* webpackChunkName: "features/groups" */'../components/panels/suggested-groups-panel'); +} + export function GroupMediaPanel() { return import(/* webpackChunkName: "features/groups" */'../components/group-media-panel'); } diff --git a/app/soapbox/pages/groups-page.tsx b/app/soapbox/pages/groups-page.tsx index c4209f572..dfe4bf58b 100644 --- a/app/soapbox/pages/groups-page.tsx +++ b/app/soapbox/pages/groups-page.tsx @@ -24,6 +24,7 @@ const GroupsPage: React.FC = ({ children }) => ( {Component => } + diff --git a/app/soapbox/pages/groups-pending-page.tsx b/app/soapbox/pages/groups-pending-page.tsx new file mode 100644 index 000000000..5d6ffc031 --- /dev/null +++ b/app/soapbox/pages/groups-pending-page.tsx @@ -0,0 +1,33 @@ +import React from 'react'; + +import { Layout } from 'soapbox/components/ui'; +import LinkFooter from 'soapbox/features/ui/components/link-footer'; +import BundleContainer from 'soapbox/features/ui/containers/bundle-container'; +import { NewGroupPanel, SuggestedGroupsPanel } from 'soapbox/features/ui/util/async-components'; + +interface IGroupsPage { + children: React.ReactNode +} + +/** Page to display groups. */ +const GroupsPendingPage: React.FC = ({ children }) => ( + <> + + {children} + + + + + {Component => } + + + + {Component => } + + + + + +); + +export default GroupsPendingPage; diff --git a/app/soapbox/queries/groups.ts b/app/soapbox/queries/groups.ts index 93fb23661..aa8635183 100644 --- a/app/soapbox/queries/groups.ts +++ b/app/soapbox/queries/groups.ts @@ -1,4 +1,5 @@ import { useInfiniteQuery, useMutation, useQuery } from '@tanstack/react-query'; +import { AxiosRequestConfig } from 'axios'; import { defineMessages, useIntl } from 'react-intl'; import { getNextLink } from 'soapbox/api'; @@ -19,6 +20,7 @@ const messages = defineMessages({ const GroupKeys = { group: (id: string) => ['groups', 'group', id] as const, myGroups: (userId: string) => ['groups', userId] as const, + pendingGroups: (userId: string) => ['groups', userId, 'pending'] as const, popularGroups: ['groups', 'popular'] as const, suggestedGroups: ['groups', 'suggested'] as const, }; @@ -33,8 +35,10 @@ const useGroupsApi = () => { return data; }; - const fetchGroups = async (endpoint: string) => { - const response = await api.get(endpoint); + const fetchGroups = async (endpoint: string, params: AxiosRequestConfig['params'] = {}) => { + const response = await api.get(endpoint, { + params, + }); const groups = [response.data].flat(); const relationships = await getGroupRelationships(groups.map((group) => group.id)); const result = groups.map((group) => { @@ -99,6 +103,52 @@ const useGroups = () => { }; }; +const usePendingGroups = () => { + const features = useFeatures(); + const account = useOwnAccount(); + const { fetchGroups } = useGroupsApi(); + + const getGroups = async (pageParam?: any): Promise> => { + const endpoint = '/api/v1/groups'; + const nextPageLink = pageParam?.link; + const uri = nextPageLink || endpoint; + const { response, groups } = await fetchGroups(uri, { + pending: true, + }); + + const link = getNextLink(response); + const hasMore = !!link; + + return { + result: groups, + hasMore, + link, + }; + }; + + const queryInfo = useInfiniteQuery( + GroupKeys.pendingGroups(account?.id as string), + ({ pageParam }: any) => getGroups(pageParam), + { + enabled: !!account && features.groupsPending, + keepPreviousData: true, + getNextPageParam: (config) => { + if (config?.hasMore) { + return { nextLink: config?.link }; + } + + return undefined; + }, + }); + + const data = flattenPages(queryInfo.data); + + return { + ...queryInfo, + groups: data || [], + }; +}; + const usePopularGroups = () => { const features = useFeatures(); const { fetchGroups } = useGroupsApi(); @@ -199,4 +249,13 @@ const useCancelMembershipRequest = () => { }); }; -export { useGroups, useGroup, usePopularGroups, useSuggestedGroups, useJoinGroup, useLeaveGroup, useCancelMembershipRequest }; +export { + useCancelMembershipRequest, + useGroup, + useGroups, + useJoinGroup, + useLeaveGroup, + usePendingGroups, + usePopularGroups, + useSuggestedGroups, +}; diff --git a/app/soapbox/utils/features.ts b/app/soapbox/utils/features.ts index fb5654bd4..c0992a74b 100644 --- a/app/soapbox/utils/features.ts +++ b/app/soapbox/utils/features.ts @@ -516,6 +516,11 @@ const getInstanceFeatures = (instance: Instance) => { */ groupsDiscovery: v.software === TRUTHSOCIAL, + /** + * Can query pending Group requests. + */ + groupsPending: v.software === TRUTHSOCIAL, + /** * Can hide follows/followers lists and counts. * @see PATCH /api/v1/accounts/update_credentials From 737c43d8478a45a76815a2e9bb558dc9042d6809 Mon Sep 17 00:00:00 2001 From: Chewbacca Date: Mon, 13 Mar 2023 13:26:22 -0400 Subject: [PATCH 3/3] Fix i18n --- app/soapbox/locales/en.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/soapbox/locales/en.json b/app/soapbox/locales/en.json index 5d6261d3f..c6a7fbb67 100644 --- a/app/soapbox/locales/en.json +++ b/app/soapbox/locales/en.json @@ -818,6 +818,10 @@ "groups.discover.suggested.title": "Suggested For You", "groups.empty.subtitle": "Start discovering groups to join or create your own.", "groups.empty.title": "No Groups yet", + "groups.pending.count": "{number, plural, one {# pending request} other {# pending requests}}", + "groups.pending.empty.subtitle": "You have no pending requests at this time.", + "groups.pending.empty.title": "No pending requests", + "groups.pending.label": "Pending Requests", "hashtag.column_header.tag_mode.all": "and {additional}", "hashtag.column_header.tag_mode.any": "or {additional}", "hashtag.column_header.tag_mode.none": "without {additional}",