diff --git a/app/soapbox/components/group-card.tsx b/app/soapbox/components/group-card.tsx index 15d8cf497..a977e1ef0 100644 --- a/app/soapbox/components/group-card.tsx +++ b/app/soapbox/components/group-card.tsx @@ -17,43 +17,53 @@ const GroupCard: React.FC = ({ group }) => { const intl = useIntl(); return ( -
- -
- {group.header && {intl.formatMessage(messages.groupHeader)}} -
- -
-
- - - - {group.relationship?.role === 'admin' ? ( - - - - - ) : group.relationship?.role === 'moderator' && ( - - - - - )} - {group.locked ? ( - - - - - ) : ( - - - - - )} - - + + {/* Group Cover Image */} + + {group.header && ( + {intl.formatMessage(messages.groupHeader)} + )} -
+ + {/* Group Avatar */} +
+ +
+ + {/* Group Info */} + + + + + {group.relationship?.role === 'admin' ? ( + + + + + ) : group.relationship?.role === 'moderator' && ( + + + + + )} + + {group.locked ? ( + + + + + ) : ( + + + + + )} + + + ); }; diff --git a/app/soapbox/components/ui/carousel/carousel.tsx b/app/soapbox/components/ui/carousel/carousel.tsx new file mode 100644 index 000000000..ddb10b37a --- /dev/null +++ b/app/soapbox/components/ui/carousel/carousel.tsx @@ -0,0 +1,109 @@ +import React, { useEffect, useState } from 'react'; + +import { useDimensions } from 'soapbox/hooks'; + +import HStack from '../hstack/hstack'; +import Icon from '../icon/icon'; + +interface ICarousel { + children: any + /** Optional height to force on controls */ + controlsHeight?: number + /** How many items in the carousel */ + itemCount: number + /** The minimum width per item */ + itemWidth: number +} + +/** + * Carousel + */ +const Carousel: React.FC = (props): JSX.Element => { + const { children, controlsHeight, itemCount, itemWidth } = props; + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [_ref, setContainerRef, { width: containerWidth }] = useDimensions(); + + const [pageSize, setPageSize] = useState(0); + const [currentPage, setCurrentPage] = useState(1); + + const numberOfPages = Math.ceil(itemCount / pageSize); + const width = containerWidth / (Math.floor(containerWidth / itemWidth)); + + const hasNextPage = currentPage < numberOfPages && numberOfPages > 1; + const hasPrevPage = currentPage > 1 && numberOfPages > 1; + + const handleNextPage = () => setCurrentPage((prevPage) => prevPage + 1); + const handlePrevPage = () => setCurrentPage((prevPage) => prevPage - 1); + + const renderChildren = () => { + if (typeof children === 'function') { + return children({ width: width || 'auto' }); + } + + return children; + }; + + useEffect(() => { + if (containerWidth) { + setPageSize(Math.round(containerWidth / width)); + } + }, [containerWidth, width]); + + return ( + +
+ +
+ +
+ + {renderChildren()} + +
+ +
+ +
+
+ ); +}; + +export default Carousel; \ No newline at end of file diff --git a/app/soapbox/components/ui/index.ts b/app/soapbox/components/ui/index.ts index 964125cf0..20bcd31c8 100644 --- a/app/soapbox/components/ui/index.ts +++ b/app/soapbox/components/ui/index.ts @@ -2,6 +2,7 @@ export { default as Accordion } from './accordion/accordion'; export { default as Avatar } from './avatar/avatar'; export { default as Banner } from './banner/banner'; export { default as Button } from './button/button'; +export { default as Carousel } from './carousel/carousel'; export { Card, CardBody, CardHeader, CardTitle } from './card/card'; export { default as Checkbox } from './checkbox/checkbox'; export { Column, ColumnHeader } from './column/column'; diff --git a/app/soapbox/features/groups/components/discover/group.tsx b/app/soapbox/features/groups/components/discover/group.tsx new file mode 100644 index 000000000..dc5d621a4 --- /dev/null +++ b/app/soapbox/features/groups/components/discover/group.tsx @@ -0,0 +1,93 @@ +import React, { forwardRef } 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 + width: number +} + +const Group = forwardRef((props: IGroup, ref: React.ForwardedRef) => { + const { group, width = 'auto' } = props; + + return ( +
+ + {group.header && ( + Group cover + )} + + + + + + + + + + + {typeof group.members_count === 'undefined' ? ( + + {group.locked ? ( + + ) : ( + + )} + + ) : ( + + {shortNumberFormat(group.members_count)} + {' '} + members + + )} + + + + +
+ + + +
+ ); +}); + +export default Group; \ No newline at end of file diff --git a/app/soapbox/features/groups/components/discover/popular-groups.tsx b/app/soapbox/features/groups/components/discover/popular-groups.tsx new file mode 100644 index 000000000..8ac7d7387 --- /dev/null +++ b/app/soapbox/features/groups/components/discover/popular-groups.tsx @@ -0,0 +1,54 @@ +import React, { useState } from 'react'; + +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'; + +const PopularGroups = () => { + const { groups, isFetching } = usePopularGroups(); + + const [groupCover, setGroupCover] = useState(null); + + return ( + + + Popular Groups + + + + {({ width }: { width: number }) => ( + <> + {isFetching ? ( + new Array(20).fill(0).map((_, idx) => ( +
+ +
+ )) + ) : ( + groups.map((group) => ( + + )) + )} + + )} +
+
+ ); +}; + +export default PopularGroups; \ No newline at end of file diff --git a/app/soapbox/features/groups/components/discover/suggested-groups.tsx b/app/soapbox/features/groups/components/discover/suggested-groups.tsx new file mode 100644 index 000000000..bf441a0ae --- /dev/null +++ b/app/soapbox/features/groups/components/discover/suggested-groups.tsx @@ -0,0 +1,54 @@ +import React, { useState } from 'react'; + +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'; + +const SuggestedGroups = () => { + const { groups, isFetching } = useSuggestedGroups(); + + const [groupCover, setGroupCover] = useState(null); + + return ( + + + Suggested For You + + + + {({ width }: { width: number }) => ( + <> + {isFetching ? ( + new Array(20).fill(0).map((_, idx) => ( +
+ +
+ )) + ) : ( + groups.map((group) => ( + + )) + )} + + )} +
+
+ ); +}; + +export default SuggestedGroups; \ No newline at end of file diff --git a/app/soapbox/features/groups/components/tab-bar.tsx b/app/soapbox/features/groups/components/tab-bar.tsx new file mode 100644 index 000000000..7a342bfc8 --- /dev/null +++ b/app/soapbox/features/groups/components/tab-bar.tsx @@ -0,0 +1,41 @@ +import React, { useMemo } from 'react'; +import { useHistory } from 'react-router-dom'; + +import { Tabs } from 'soapbox/components/ui'; + +import type { Item } from 'soapbox/components/ui/tabs/tabs'; + +export enum TabItems { + MY_GROUPS = 'MY_GROUPS', + FIND_GROUPS = 'FIND_GROUPS' +} + +interface ITabBar { + activeTab: TabItems +} + +const TabBar = ({ activeTab }: ITabBar) => { + const history = useHistory(); + + const tabItems: Item[] = useMemo(() => ([ + { + text: 'My Groups', + action: () => history.push('/groups'), + name: TabItems.MY_GROUPS, + }, + { + text: 'Find Groups', + action: () => history.push('/groups/discover'), + name: TabItems.FIND_GROUPS, + }, + ]), []); + + return ( + + ); +}; + +export default TabBar; \ No newline at end of file diff --git a/app/soapbox/features/groups/discover.tsx b/app/soapbox/features/groups/discover.tsx new file mode 100644 index 000000000..3145e3a58 --- /dev/null +++ b/app/soapbox/features/groups/discover.tsx @@ -0,0 +1,22 @@ +import React from 'react'; + +import { Stack } from 'soapbox/components/ui'; + +import PopularGroups from './components/discover/popular-groups'; +import SuggestedGroups from './components/discover/suggested-groups'; +import TabBar, { TabItems } from './components/tab-bar'; + +const Discover: React.FC = () => { + return ( + + + + + + + + + ); +}; + +export default Discover; diff --git a/app/soapbox/features/groups/index.tsx b/app/soapbox/features/groups/index.tsx index 85a092e0b..f48bd1520 100644 --- a/app/soapbox/features/groups/index.tsx +++ b/app/soapbox/features/groups/index.tsx @@ -1,78 +1,65 @@ -import React, { useEffect } from 'react'; +import React from 'react'; import { FormattedMessage } from 'react-intl'; import { Link } from 'react-router-dom'; -import { createSelector } from 'reselect'; -import { fetchGroups } from 'soapbox/actions/groups'; import { openModal } from 'soapbox/actions/modals'; import GroupCard from 'soapbox/components/group-card'; import ScrollableList from 'soapbox/components/scrollable-list'; -import { Button, Column, Spinner, Stack, Text } from 'soapbox/components/ui'; -import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; +import { Button, Stack, Text } from 'soapbox/components/ui'; +import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks'; +import { useGroups } from 'soapbox/queries/groups'; import { PERMISSION_CREATE_GROUPS, hasPermission } from 'soapbox/utils/permissions'; import PlaceholderGroupCard from '../placeholder/components/placeholder-group-card'; -import type { List as ImmutableList } from 'immutable'; -import type { RootState } from 'soapbox/store'; +import TabBar, { TabItems } from './components/tab-bar'; + import type { Group as GroupEntity } from 'soapbox/types/entities'; -const getOrderedGroups = createSelector([ - (state: RootState) => state.groups.items, - (state: RootState) => state.groups.isLoading, - (state: RootState) => state.group_relationships, -], (groups, isLoading, group_relationships) => ({ - groups: (groups.toList().filter((item: GroupEntity | false) => !!item) as ImmutableList) - .map((item) => item.set('relationship', group_relationships.get(item.id) || null)) - .filter((item) => item.relationship?.member) - .sort((a, b) => a.display_name.localeCompare(b.display_name)), - isLoading, -})); +// const getOrderedGroups = createSelector([ +// (state: RootState) => state.groups.items, +// (state: RootState) => state.group_relationships, +// ], (groups, group_relationships) => ({ +// groups: (groups.toList().filter((item: GroupEntity | false) => !!item) as ImmutableList) +// .map((item) => item.set('relationship', group_relationships.get(item.id) || null)) +// .filter((item) => item.relationship?.member) +// .sort((a, b) => a.display_name.localeCompare(b.display_name)), +// })); + +const EmptyMessage = () => ( + + + + + + + + + + + +); const Groups: React.FC = () => { const dispatch = useAppDispatch(); + const features = useFeatures(); - const { groups, isLoading } = useAppSelector((state) => getOrderedGroups(state)); const canCreateGroup = useAppSelector((state) => hasPermission(state, PERMISSION_CREATE_GROUPS)); - useEffect(() => { - dispatch(fetchGroups()); - }, []); + const { groups, isLoading } = useGroups(); const createGroup = () => { dispatch(openModal('MANAGE_GROUP')); }; - if (!groups) { - return ( - - - - ); - } - - const emptyMessage = ( - - - - - - - - - - - - ); - return ( - + {canCreateGroup && ( )} + + {features.groupsDiscovery && ( + + )} + } itemClassName='py-3 first:pt-0 last:pb-0' isLoading={isLoading} - showLoading={isLoading && !groups.count()} + showLoading={isLoading && groups.length === 0} placeholderComponent={PlaceholderGroupCard} placeholderCount={3} > diff --git a/app/soapbox/features/placeholder/components/placeholder-group-card.tsx b/app/soapbox/features/placeholder/components/placeholder-group-card.tsx index 44ece7320..f07012f8f 100644 --- a/app/soapbox/features/placeholder/components/placeholder-group-card.tsx +++ b/app/soapbox/features/placeholder/components/placeholder-group-card.tsx @@ -5,23 +5,26 @@ import { HStack, Stack, Text } from 'soapbox/components/ui'; import { generateText, randomIntFromInterval } from '../utils'; const PlaceholderGroupCard = () => { - const groupNameLength = randomIntFromInterval(5, 25); - const roleLength = randomIntFromInterval(5, 15); - const privacyLength = randomIntFromInterval(5, 15); + const groupNameLength = randomIntFromInterval(12, 20); return ( -
- -
-
-
-
+
+ + {/* Group Cover Image */} +
+ + {/* Group Avatar */} +
+
- - {generateText(groupNameLength)} - - {generateText(roleLength)} - {generateText(privacyLength)} + + {/* Group Info */} + + {generateText(groupNameLength)} + + + {generateText(6)} + {generateText(6)} diff --git a/app/soapbox/features/placeholder/components/placeholder-group-discover.tsx b/app/soapbox/features/placeholder/components/placeholder-group-discover.tsx new file mode 100644 index 000000000..767dd5e0b --- /dev/null +++ b/app/soapbox/features/placeholder/components/placeholder-group-discover.tsx @@ -0,0 +1,38 @@ +import React from 'react'; + +import { HStack, Stack, Text } from 'soapbox/components/ui'; + +import { generateText, randomIntFromInterval } from '../utils'; + +const PlaceholderGroupDiscover = () => { + const groupNameLength = randomIntFromInterval(12, 20); + + return ( + + + {/* Group Cover Image */} +
+ + + {/* Group Avatar */} +
+ + {/* Group Info */} + + {generateText(groupNameLength)} + + + {generateText(6)} + {generateText(6)} + + + + + + {/* Join Group Button */} +
+ + ); +}; + +export default PlaceholderGroupDiscover; diff --git a/app/soapbox/features/ui/components/panels/new-group-panel.tsx b/app/soapbox/features/ui/components/panels/new-group-panel.tsx index 9710eeb82..9eba9c7e0 100644 --- a/app/soapbox/features/ui/components/panels/new-group-panel.tsx +++ b/app/soapbox/features/ui/components/panels/new-group-panel.tsx @@ -21,7 +21,7 @@ const NewGroupPanel = () => { - + @@ -30,12 +30,11 @@ const NewGroupPanel = () => { ); diff --git a/app/soapbox/features/ui/index.tsx b/app/soapbox/features/ui/index.tsx index 3a69b82a7..f184b8a8d 100644 --- a/app/soapbox/features/ui/index.tsx +++ b/app/soapbox/features/ui/index.tsx @@ -115,6 +115,7 @@ import { EventDiscussion, Events, Groups, + GroupsDiscover, GroupMembers, GroupTimeline, ManageGroup, @@ -282,6 +283,7 @@ const SwitchingColumnsArea: React.FC = ({ children }) => {features.groups && } + {features.groupsDiscovery && } {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 6e18f5771..ba16dae69 100644 --- a/app/soapbox/features/ui/util/async-components.ts +++ b/app/soapbox/features/ui/util/async-components.ts @@ -542,6 +542,10 @@ export function Groups() { return import(/* webpackChunkName: "features/groups" */'../../groups'); } +export function GroupsDiscover() { + return import(/* webpackChunkName: "features/groups/discover" */'../../groups/discover'); +} + export function GroupMembers() { return import(/* webpackChunkName: "features/groups" */'../../group/group-members'); } diff --git a/app/soapbox/locales/en.json b/app/soapbox/locales/en.json index dc55b1f83..f9b638009 100644 --- a/app/soapbox/locales/en.json +++ b/app/soapbox/locales/en.json @@ -987,9 +987,9 @@ "new_event_panel.action": "Create event", "new_event_panel.subtitle": "Can't find what you're looking for? Schedule your own event.", "new_event_panel.title": "Create New Event", - "new_group_panel.action": "Create group", + "new_group_panel.action": "Create Group", "new_group_panel.subtitle": "Can't find what you're looking for? Start your own private or public group.", - "new_group_panel.title": "Create New Group", + "new_group_panel.title": "Create Group", "notification.favourite": "{name} liked your post", "notification.follow": "{name} followed you", "notification.follow_request": "{name} has requested to follow you", diff --git a/app/soapbox/normalizers/group.ts b/app/soapbox/normalizers/group.ts index 397ec5eee..2c94d873d 100644 --- a/app/soapbox/normalizers/group.ts +++ b/app/soapbox/normalizers/group.ts @@ -29,6 +29,7 @@ export const GroupRecord = ImmutableRecord({ id: '', locked: false, membership_required: false, + members_count: undefined as number | undefined, note: '', statuses_visibility: 'public', uri: '', diff --git a/app/soapbox/queries/groups.ts b/app/soapbox/queries/groups.ts new file mode 100644 index 000000000..fbacb7182 --- /dev/null +++ b/app/soapbox/queries/groups.ts @@ -0,0 +1,111 @@ +import { useInfiniteQuery, useQuery } from '@tanstack/react-query'; + +import { fetchGroupRelationships } from 'soapbox/actions/groups'; +import { importFetchedGroups } from 'soapbox/actions/importer'; +import { getNextLink } from 'soapbox/api'; +import { useApi, useAppDispatch, useFeatures, useOwnAccount } from 'soapbox/hooks'; +import { normalizeGroup } from 'soapbox/normalizers'; +import { Group } from 'soapbox/types/entities'; +import { flattenPages, PaginatedResult } from 'soapbox/utils/queries'; + +const GroupKeys = { + myGroups: (userId: string) => ['groups', userId] as const, + popularGroups: ['groups', 'popular'] as const, + suggestedGroups: ['groups', 'suggested'] as const, +}; + +const useGroups = () => { + const api = useApi(); + const account = useOwnAccount(); + const dispatch = useAppDispatch(); + + const getGroups = async (pageParam?: any): Promise> => { + const endpoint = '/api/mock/groups'; // '/api/v1/groups'; + const nextPageLink = pageParam?.link; + const uri = nextPageLink || endpoint; + const response = await api.get(uri); + const { data } = response; + + const link = getNextLink(response); + const hasMore = !!link; + const result = data.map(normalizeGroup); + + // Note: Temporary while part of Groups is using Redux + dispatch(importFetchedGroups(result)); + dispatch(fetchGroupRelationships(result.map((item) => item.id))); + + return { + result, + hasMore, + link, + }; + }; + + const queryInfo = useInfiniteQuery( + GroupKeys.myGroups(account?.id as string), + ({ pageParam }: any) => getGroups(pageParam), + { + enabled: !!account, + 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 api = useApi(); + const features = useFeatures(); + + const getQuery = async () => { + const { data } = await api.get('/api/mock/groups'); // '/api/v1/truth/trends/groups' + const result = data.map(normalizeGroup); + + return result; + }; + + const queryInfo = useQuery(GroupKeys.popularGroups, getQuery, { + enabled: features.groupsDiscovery, + placeholderData: [], + }); + + return { + groups: queryInfo.data || [], + ...queryInfo, + }; +}; + +const useSuggestedGroups = () => { + const api = useApi(); + const features = useFeatures(); + + const getQuery = async () => { + const { data } = await api.get('/api/mock/groups'); // /api/v1/truth/suggestions/groups + const result = data.map(normalizeGroup); + + return result; + }; + + const queryInfo = useQuery(GroupKeys.suggestedGroups, getQuery, { + enabled: features.groupsDiscovery, + placeholderData: [], + }); + + return { + groups: queryInfo.data || [], + ...queryInfo, + }; +}; + +export { useGroups, usePopularGroups, useSuggestedGroups }; diff --git a/app/soapbox/utils/features.ts b/app/soapbox/utils/features.ts index 3b1c14b77..8956411dd 100644 --- a/app/soapbox/utils/features.ts +++ b/app/soapbox/utils/features.ts @@ -499,6 +499,11 @@ const getInstanceFeatures = (instance: Instance) => { */ groups: false, + /** + * Can see trending/suggested Groups. + */ + groupsDiscovery: v.software === TRUTHSOCIAL, + /** * Can hide follows/followers lists and counts. * @see PATCH /api/v1/accounts/update_credentials diff --git a/package.json b/package.json index 79d36fa67..7410e33a5 100644 --- a/package.json +++ b/package.json @@ -205,6 +205,7 @@ "@storybook/manager-webpack5": "^6.5.16", "@storybook/react": "^6.5.16", "@storybook/testing-library": "^0.0.13", + "@tailwindcss/aspect-ratio": "^0.4.2", "@testing-library/jest-dom": "^5.16.4", "@testing-library/react-hooks": "^8.0.1", "@testing-library/user-event": "^14.0.3", diff --git a/tailwind.config.cjs b/tailwind.config.cjs index d5c9d5f06..10270a77f 100644 --- a/tailwind.config.cjs +++ b/tailwind.config.cjs @@ -100,5 +100,6 @@ module.exports = { require('@tailwindcss/forms'), require('@tailwindcss/line-clamp'), require('@tailwindcss/typography'), + require('@tailwindcss/aspect-ratio'), ], }; diff --git a/yarn.lock b/yarn.lock index 482b61c66..3e8a57929 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3831,6 +3831,11 @@ resolved "https://registry.yarnpkg.com/@tabler/icons/-/icons-2.4.0.tgz#34b1b0d818dc00926b956c3424bff48b89a5b439" integrity sha512-JZY9Kk3UsQoqp7Rw/BuWw1PrkRwv5h0psjJBbj+Cn9UVyhdzr5vztg2mywXBAJ+jFBUL/pjnVcIvOzKFw4CXng== +"@tailwindcss/aspect-ratio@^0.4.2": + version "0.4.2" + resolved "https://registry.yarnpkg.com/@tailwindcss/aspect-ratio/-/aspect-ratio-0.4.2.tgz#9ffd52fee8e3c8b20623ff0dcb29e5c21fb0a9ba" + integrity sha512-8QPrypskfBa7QIMuKHg2TA7BqES6vhBrDLOv8Unb6FcFyd3TjKbc6lcmb9UPQHxfl24sXoJ41ux/H7qQQvfaSQ== + "@tailwindcss/forms@^0.5.3": version "0.5.3" resolved "https://registry.yarnpkg.com/@tailwindcss/forms/-/forms-0.5.3.tgz#e4d7989686cbcaf416c53f1523df5225332a86e7"