diff --git a/app/soapbox/components/group-card.tsx b/app/soapbox/components/group-card.tsx index 15d8cf4971..a977e1ef01 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/sidebar-menu.tsx b/app/soapbox/components/sidebar-menu.tsx index ffe3a0e983..0c68b2aff9 100644 --- a/app/soapbox/components/sidebar-menu.tsx +++ b/app/soapbox/components/sidebar-menu.tsx @@ -10,7 +10,7 @@ import { closeSidebar } from 'soapbox/actions/sidebar'; import Account from 'soapbox/components/account'; import { Stack } from 'soapbox/components/ui'; import ProfileStats from 'soapbox/features/ui/components/profile-stats'; -import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks'; +import { useAppDispatch, useAppSelector, useGroupsPath, useFeatures } from 'soapbox/hooks'; import { makeGetAccount, makeGetOtherAccounts } from 'soapbox/selectors'; import { Divider, HStack, Icon, IconButton, Text } from './ui'; @@ -90,6 +90,7 @@ const SidebarMenu: React.FC = (): JSX.Element | null => { const sidebarOpen = useAppSelector((state) => state.sidebar.sidebarOpen); const settings = useAppSelector((state) => getSettings(state)); const followRequestsCount = useAppSelector((state) => state.user_lists.follow_requests.items.count()); + const groupsPath = useGroupsPath(); const closeButtonRef = React.useRef(null); @@ -210,7 +211,7 @@ const SidebarMenu: React.FC = (): JSX.Element | null => { {features.groups && ( { const features = useFeatures(); const settings = useSettings(); const account = useOwnAccount(); + const groupsPath = useGroupsPath(); + const notificationCount = useAppSelector((state) => state.notifications.unread); const followRequestsCount = useAppSelector((state) => state.user_lists.follow_requests.items.count()); const dashboardCount = useAppSelector((state) => state.admin.openReports.count() + state.admin.awaitingApproval.count()); @@ -135,7 +137,7 @@ const SidebarNavigation = () => { {features.groups && ( } /> diff --git a/app/soapbox/components/ui/carousel/carousel.tsx b/app/soapbox/components/ui/carousel/carousel.tsx new file mode 100644 index 0000000000..ddb10b37a7 --- /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 964125cf0f..20bcd31c8d 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/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 0000000000..eb2cf670bf --- /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 0000000000..86e9baac87 --- /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 0000000000..72e4454e7a --- /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 0000000000..53f27f7099 --- /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 e341a28f28..7b532ddccc 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,93 +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 0000000000..e4dd33e54c --- /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 0000000000..fdbbe29771 --- /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 0000000000..6b79ecda57 --- /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 f4cf2f5744..f80343c1e4 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 new file mode 100644 index 0000000000..a596f95f27 --- /dev/null +++ b/app/soapbox/features/groups/components/discover/group.tsx @@ -0,0 +1,81 @@ +import React, { forwardRef } from 'react'; +import { FormattedMessage } from 'react-intl'; +import { Link } from 'react-router-dom'; + +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'; + +interface IGroup { + group: GroupEntity + width?: number +} + +const Group = forwardRef((props: IGroup, ref: React.ForwardedRef) => { + const { group, width = 'auto' } = props; + + return ( +
+ + + {group.header && ( + Group cover + )} + + + + + + + + + + + + + + + +
+ + + + +
+ ); +}); + +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 0000000000..8ac7d7387f --- /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/search/__tests__/recent-searches.test.tsx b/app/soapbox/features/groups/components/discover/search/__tests__/recent-searches.test.tsx new file mode 100644 index 0000000000..8c0e54262e --- /dev/null +++ b/app/soapbox/features/groups/components/discover/search/__tests__/recent-searches.test.tsx @@ -0,0 +1,79 @@ +import userEvent from '@testing-library/user-event'; +import { Map as ImmutableMap } from 'immutable'; +import React from 'react'; +import { VirtuosoMockContext } from 'react-virtuoso'; + +import { render, screen, waitFor } from 'soapbox/jest/test-helpers'; +import { normalizeAccount } from 'soapbox/normalizers'; +import { groupSearchHistory } from 'soapbox/settings'; +import { clearRecentGroupSearches, saveGroupSearch } from 'soapbox/utils/groups'; + +import RecentSearches from '../recent-searches'; + +const userId = '1'; +const store = { + me: userId, + accounts: ImmutableMap({ + [userId]: normalizeAccount({ + id: userId, + acct: 'justin-username', + display_name: 'Justin L', + avatar: 'test.jpg', + chats_onboarded: false, + }), + }), +}; + +const renderApp = (children: React.ReactNode) => ( + render( + + {children} + , + undefined, + store, + ) +); + +describe('', () => { + describe('with recent searches', () => { + beforeEach(() => { + saveGroupSearch(userId, 'foobar'); + }); + + afterEach(() => { + clearRecentGroupSearches(userId); + }); + + it('should render the recent searches', async () => { + renderApp(); + + await waitFor(() => { + expect(screen.getByTestId('recent-search')).toBeInTheDocument(); + }); + }); + + it('should support clearing recent searches', async () => { + renderApp(); + + expect(groupSearchHistory.get(userId)).toHaveLength(1); + await userEvent.click(screen.getByTestId('clear-recent-searches')); + expect(groupSearchHistory.get(userId)).toBeNull(); + }); + + it('should support click events on the results', async () => { + const handler = jest.fn(); + renderApp(); + expect(handler.mock.calls.length).toEqual(0); + await userEvent.click(screen.getByTestId('recent-search-result')); + expect(handler.mock.calls.length).toEqual(1); + }); + }); + + describe('without recent searches', () => { + it('should render the blankslate', async () => { + renderApp(); + + expect(screen.getByTestId('recent-searches-blankslate')).toBeInTheDocument(); + }); + }); +}); \ No newline at end of file diff --git a/app/soapbox/features/groups/components/discover/search/__tests__/search.test.tsx b/app/soapbox/features/groups/components/discover/search/__tests__/search.test.tsx new file mode 100644 index 0000000000..698608363e --- /dev/null +++ b/app/soapbox/features/groups/components/discover/search/__tests__/search.test.tsx @@ -0,0 +1,62 @@ +import React from 'react'; + +import { __stub } from 'soapbox/api'; +import { render, screen, waitFor } from 'soapbox/jest/test-helpers'; +import { normalizeGroup, normalizeInstance } from 'soapbox/normalizers'; + +import Search from '../search'; + +const store = { + instance: normalizeInstance({ + version: '3.4.1 (compatible; TruthSocial 1.0.0+unreleased)', + }), +}; + +const renderApp = (children: React.ReactElement) => render(children, undefined, store); + +describe('', () => { + describe('with no results', () => { + beforeEach(() => { + __stub((mock) => { + mock.onGet('/api/v1/groups/search').reply(200, []); + }); + }); + + it('should render the blankslate', async () => { + renderApp(); + + await waitFor(() => { + expect(screen.getByTestId('no-results')).toBeInTheDocument(); + }); + }); + }); + + describe('with results', () => { + beforeEach(() => { + __stub((mock) => { + mock.onGet('/api/v1/groups/search').reply(200, [ + normalizeGroup({ + display_name: 'Group', + id: '1', + }), + ]); + }); + }); + + it('should render the results', async () => { + renderApp(); + + await waitFor(() => { + expect(screen.getByTestId('results')).toBeInTheDocument(); + }); + }); + }); + + describe('before starting a search', () => { + it('should render the RecentSearches component', () => { + renderApp(); + + expect(screen.getByTestId('recent-searches')).toBeInTheDocument(); + }); + }); +}); \ No newline at end of file diff --git a/app/soapbox/features/groups/components/discover/search/no-results-blankslate.tsx b/app/soapbox/features/groups/components/discover/search/no-results-blankslate.tsx new file mode 100644 index 0000000000..1713488468 --- /dev/null +++ b/app/soapbox/features/groups/components/discover/search/no-results-blankslate.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import { FormattedMessage } from 'react-intl'; + +import { Stack, Text } from 'soapbox/components/ui'; + +export default () => ( + + + + + + + + + +); \ No newline at end of file diff --git a/app/soapbox/features/groups/components/discover/search/recent-searches.tsx b/app/soapbox/features/groups/components/discover/search/recent-searches.tsx new file mode 100644 index 0000000000..1fe5e1d2f5 --- /dev/null +++ b/app/soapbox/features/groups/components/discover/search/recent-searches.tsx @@ -0,0 +1,90 @@ +import React, { useState } from 'react'; +import { FormattedMessage } from 'react-intl'; +import { Virtuoso } from 'react-virtuoso'; + +import { HStack, Icon, Stack, Text } from 'soapbox/components/ui'; +import { useOwnAccount } from 'soapbox/hooks'; +import { groupSearchHistory } from 'soapbox/settings'; +import { clearRecentGroupSearches } from 'soapbox/utils/groups'; + +interface Props { + onSelect(value: string): void +} + +export default (props: Props) => { + const { onSelect } = props; + + const me = useOwnAccount(); + + const [recentSearches, setRecentSearches] = useState(groupSearchHistory.get(me?.id as string) || []); + + const onClearRecentSearches = () => { + clearRecentGroupSearches(me?.id as string); + setRecentSearches([]); + }; + + return ( + + {recentSearches.length > 0 ? ( + <> + + + + + + + + + ( +
+ +
+ )} + /> + + ) : ( + + + + + + + + + + )} +
+ ); +}; \ No newline at end of file diff --git a/app/soapbox/features/groups/components/discover/search/results.tsx b/app/soapbox/features/groups/components/discover/search/results.tsx new file mode 100644 index 0000000000..cfbc74039b --- /dev/null +++ b/app/soapbox/features/groups/components/discover/search/results.tsx @@ -0,0 +1,169 @@ +import clsx from 'clsx'; +import React, { useCallback, useState } from 'react'; +import { FormattedMessage } from 'react-intl'; +import { Components, Virtuoso, VirtuosoGrid } from 'react-virtuoso'; + +import { Avatar, Button, HStack, Icon, Stack, Text } from 'soapbox/components/ui'; +import { useGroupSearch } from 'soapbox/queries/groups/search'; +import { Group } from 'soapbox/types/entities'; +import { shortNumberFormat } from 'soapbox/utils/numbers'; + +import GroupComp from '../group'; + +interface Props { + groupSearchResult: ReturnType +} + +enum Layout { + LIST = 'LIST', + GRID = 'GRID' +} + +const GridList: Components['List'] = React.forwardRef((props, ref) => { + const { context, ...rest } = props; + return
; +}); + +export default (props: Props) => { + const { groupSearchResult } = props; + + const [layout, setLayout] = useState(Layout.LIST); + + const { groups, hasNextPage, isFetching, fetchNextPage } = groupSearchResult; + + const handleLoadMore = () => { + if (hasNextPage && !isFetching) { + fetchNextPage(); + } + }; + + 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) => ( +
+ +
+ ), []); + + return ( + + + + + + + + + + + + + + {layout === Layout.LIST ? ( + renderGroupList(group, index)} + endReached={handleLoadMore} + /> + ) : ( + renderGroupGrid(group, index)} + components={{ + Item: (props) => ( +
+ ), + List: GridList, + }} + endReached={handleLoadMore} + /> + )} + + ); +}; \ No newline at end of file diff --git a/app/soapbox/features/groups/components/discover/search/search.tsx b/app/soapbox/features/groups/components/discover/search/search.tsx new file mode 100644 index 0000000000..083dab8d57 --- /dev/null +++ b/app/soapbox/features/groups/components/discover/search/search.tsx @@ -0,0 +1,64 @@ +import React, { useEffect } from 'react'; + +import { Stack } from 'soapbox/components/ui'; +import PlaceholderGroupSearch from 'soapbox/features/placeholder/components/placeholder-group-search'; +import { useDebounce, useOwnAccount } from 'soapbox/hooks'; +import { useGroupSearch } from 'soapbox/queries/groups/search'; +import { saveGroupSearch } from 'soapbox/utils/groups'; + +import NoResultsBlankslate from './no-results-blankslate'; +import RecentSearches from './recent-searches'; +import Results from './results'; + +interface Props { + onSelect(value: string): void + searchValue: string +} + +export default (props: Props) => { + const { onSelect, searchValue } = props; + + const me = useOwnAccount(); + const debounce = useDebounce; + + const debouncedValue = debounce(searchValue as string, 300); + const debouncedValueToSave = debounce(searchValue as string, 1000); + + const groupSearchResult = useGroupSearch(debouncedValue); + const { groups, isFetching, isFetched } = groupSearchResult; + + const hasSearchResults = isFetched && groups.length > 0; + const hasNoSearchResults = isFetched && groups.length === 0; + + useEffect(() => { + if (debouncedValueToSave && debouncedValueToSave.length >= 0) { + saveGroupSearch(me?.id as string, debouncedValueToSave); + } + }, [debouncedValueToSave]); + + if (isFetching) { + return ( + + + + + + ); + } + + if (hasNoSearchResults) { + return ; + } + + if (hasSearchResults) { + return ( + + ); + } + + return ( + + ); +}; \ 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 0000000000..bf441a0aea --- /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 0000000000..7a342bfc83 --- /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 0000000000..4e0c0c70a8 --- /dev/null +++ b/app/soapbox/features/groups/discover.tsx @@ -0,0 +1,81 @@ +import React, { useState } from 'react'; +import { defineMessages, useIntl } from 'react-intl'; + +import { HStack, Icon, IconButton, Input, Stack } from 'soapbox/components/ui'; + +import PopularGroups from './components/discover/popular-groups'; +import Search from './components/discover/search/search'; +import SuggestedGroups from './components/discover/suggested-groups'; +import TabBar, { TabItems } from './components/tab-bar'; + +const messages = defineMessages({ + placeholder: { id: 'groups.discover.search.placeholder', defaultMessage: 'Search' }, +}); + +const Discover: React.FC = () => { + const intl = useIntl(); + + const [isSearching, setIsSearching] = useState(false); + const [value, setValue] = useState(''); + + const hasSearchValue = value && value.length > 0; + + const cancelSearch = () => { + clearValue(); + setIsSearching(false); + }; + + const clearValue = () => setValue(''); + + return ( + + + + + + {isSearching ? ( + + ) : null} + + setValue(event.target.value)} + onFocus={() => setIsSearching(true)} + outerClassName='mt-0 w-full' + theme='search' + append={ + + } + /> + + + {isSearching ? ( + setValue(newValue)} + /> + ) : ( + <> + + + + )} + + + ); +}; + +export default Discover; diff --git a/app/soapbox/features/groups/index.tsx b/app/soapbox/features/groups/index.tsx index 85a092e0be..f48bd1520d 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 44ece7320f..f07012f8f2 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 0000000000..767dd5e0b1 --- /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/placeholder/components/placeholder-group-search.tsx b/app/soapbox/features/placeholder/components/placeholder-group-search.tsx new file mode 100644 index 0000000000..b2e2dc6f84 --- /dev/null +++ b/app/soapbox/features/placeholder/components/placeholder-group-search.tsx @@ -0,0 +1,43 @@ +import React from 'react'; + +import { HStack, Stack, Text } from 'soapbox/components/ui'; + +import { generateText, randomIntFromInterval } from '../utils'; + +export default () => { + const groupNameLength = randomIntFromInterval(12, 20); + + return ( + + + {/* Group Avatar */} +
+ + + + {generateText(groupNameLength)} + + + + + {generateText(6)} + + + + + + {generateText(6)} + + + + + + {/* Join Group Button */} +
+ + ); +}; 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 9710eeb82a..9eba9c7e0f 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 2e8a3e177d..b675e4de51 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 94d7d7c738..8f55da6bc1 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/hooks/__tests__/useGroupsPath.test.ts b/app/soapbox/hooks/__tests__/useGroupsPath.test.ts new file mode 100644 index 0000000000..d2ffb2452e --- /dev/null +++ b/app/soapbox/hooks/__tests__/useGroupsPath.test.ts @@ -0,0 +1,73 @@ +import { Map as ImmutableMap } from 'immutable'; + +import { __stub } from 'soapbox/api'; +import { renderHook, waitFor } from 'soapbox/jest/test-helpers'; +import { normalizeAccount, normalizeGroup, normalizeInstance } from 'soapbox/normalizers'; + +import { useGroupsPath } from '../useGroupsPath'; + +describe('useGroupsPath()', () => { + test('without the groupsDiscovery feature', () => { + const store = { + instance: normalizeInstance({ + version: '2.7.2 (compatible; Pleroma 2.3.0)', + }), + }; + + const { result } = renderHook(useGroupsPath, undefined, store); + + expect(result.current).toEqual('/groups'); + }); + + describe('with the "groupsDiscovery" feature', () => { + let store: any; + + beforeEach(() => { + const userId = '1'; + store = { + instance: normalizeInstance({ + version: '3.4.1 (compatible; TruthSocial 1.0.0+unreleased)', + }), + me: userId, + accounts: ImmutableMap({ + [userId]: normalizeAccount({ + id: userId, + acct: 'justin-username', + display_name: 'Justin L', + avatar: 'test.jpg', + chats_onboarded: false, + }), + }), + }; + }); + + describe('when the user has no groups', () => { + test('should default to the discovery page', () => { + const { result } = renderHook(useGroupsPath, undefined, store); + + expect(result.current).toEqual('/groups/discover'); + }); + }); + + describe('when the user has groups', () => { + beforeEach(() => { + __stub((mock) => { + mock.onGet('/api/v1/groups').reply(200, [ + normalizeGroup({ + display_name: 'Group', + id: '1', + }), + ]); + }); + }); + + test('should default to the discovery page', async () => { + const { result } = renderHook(useGroupsPath, undefined, store); + + await waitFor(() => { + expect(result.current).toEqual('/groups'); + }); + }); + }); + }); +}); diff --git a/app/soapbox/hooks/index.ts b/app/soapbox/hooks/index.ts index 6460938b65..afefefd72e 100644 --- a/app/soapbox/hooks/index.ts +++ b/app/soapbox/hooks/index.ts @@ -5,6 +5,7 @@ export { useAppSelector } from './useAppSelector'; export { useClickOutside } from './useClickOutside'; export { useCompose } from './useCompose'; export { useDebounce } from './useDebounce'; +export { useGroupsPath } from './useGroupsPath'; export { useDimensions } from './useDimensions'; export { useFeatures } from './useFeatures'; export { useInstance } from './useInstance'; diff --git a/app/soapbox/hooks/useGroupsPath.ts b/app/soapbox/hooks/useGroupsPath.ts new file mode 100644 index 0000000000..8a4759f324 --- /dev/null +++ b/app/soapbox/hooks/useGroupsPath.ts @@ -0,0 +1,23 @@ +import { useGroups } from 'soapbox/queries/groups'; + +import { useFeatures } from './useFeatures'; + +/** + * Determine the correct URL to use for /groups. + * If the user does not have any Groups, let's default to the discovery tab. + * Otherwise, let's default to My Groups. + * + * @returns String (as link) + */ +const useGroupsPath = () => { + const features = useFeatures(); + const { groups } = useGroups(); + + if (!features.groupsDiscovery) { + return '/groups'; + } + + return groups.length > 0 ? '/groups' : '/groups/discover'; +}; + +export { useGroupsPath }; \ No newline at end of file diff --git a/app/soapbox/locales/de.json b/app/soapbox/locales/de.json index 7ca8d58467..912aaaa271 100644 --- a/app/soapbox/locales/de.json +++ b/app/soapbox/locales/de.json @@ -737,7 +737,7 @@ "group.group_mod_unblock": "Entblocken", "group.group_mod_unblock.success": "@{name} in der Gruppe entblockt", "group.header.alt": "Gruppentitel", - "group.join": "Gruppe beitreten", + "group.join.public": "Gruppe beitreten", "group.join.request_success": "Mitgliedschaft in der Gruppe angefragt", "group.join.success": "Gruppe beigetreten", "group.leave": "Gruppe verlassen", @@ -746,7 +746,7 @@ "group.moderator_subheading": "Moderator:innen der Gruppe", "group.privacy.locked": "Privat", "group.privacy.public": "Öffentlich", - "group.request_join": "Mitgliedschaft in der Gruppe anfragen", + "group.join.private": "Mitgliedschaft in der Gruppe anfragen", "group.role.admin": "Administrator:in", "group.role.moderator": "Moderator:in", "group.tabs.all": "Alle", diff --git a/app/soapbox/locales/en.json b/app/soapbox/locales/en.json index 3d4dd42231..657ab8d19d 100644 --- a/app/soapbox/locales/en.json +++ b/app/soapbox/locales/en.json @@ -755,7 +755,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", @@ -773,21 +773,30 @@ "group.group_mod_unblock": "Unblock", "group.group_mod_unblock.success": "Unblocked @{name} from group", "group.header.alt": "Group header", - "group.join": "Join group", + "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", - "group.request_join": "Request to join group", "group.role.admin": "Admin", "group.role.moderator": "Moderator", "group.tabs.all": "All", "group.tabs.members": "Members", "group.user_subheading": "Users", + "groups.discover.search.no_results.subtitle": "Try searching for another group.", + "groups.discover.search.no_results.title": "No matches found", + "groups.discover.search.placeholder": "Search", + "groups.discover.search.recent_searches.blankslate.subtitle": "Search group names, topics or keywords", + "groups.discover.search.recent_searches.blankslate.title": "No recent searches", + "groups.discover.search.recent_searches.clear_all": "Clear all", + "groups.discover.search.recent_searches.title": "Recent searches", + "groups.discover.search.results.groups": "Groups", + "groups.discover.search.results.member_count": "{members, plural, one {member} other {members}}", "groups.empty.subtitle": "Start discovering groups to join or create your own.", "groups.empty.title": "No Groups yet", "hashtag.column_header.tag_mode.all": "and {additional}", @@ -996,9 +1005,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/locales/es.json b/app/soapbox/locales/es.json index 703c3117e8..8dd75f64e9 100644 --- a/app/soapbox/locales/es.json +++ b/app/soapbox/locales/es.json @@ -738,7 +738,7 @@ "group.group_mod_unblock": "Desbloquear", "group.group_mod_unblock.success": "Desbloquear a @{name} del grupo", "group.header.alt": "Encabezado del grupo", - "group.join": "Unirse al grupo", + "group.join.public": "Unirse al grupo", "group.join.request_success": "Solicitud de unión al grupo", "group.join.success": "Se unió al grupo", "group.leave": "Dejar el grupo", @@ -747,7 +747,7 @@ "group.moderator_subheading": "Moderadores del grupo", "group.privacy.locked": "Privado", "group.privacy.public": "Público", - "group.request_join": "Solicitud de ingreso en el grupo", + "group.join.private": "Solicitud de ingreso en el grupo", "group.role.admin": "Administrador", "group.role.moderator": "Moderador", "group.tabs.all": "Todos", diff --git a/app/soapbox/locales/it.json b/app/soapbox/locales/it.json index e46960f5a2..e691a61c4b 100644 --- a/app/soapbox/locales/it.json +++ b/app/soapbox/locales/it.json @@ -738,7 +738,7 @@ "group.group_mod_unblock": "Sblocca", "group.group_mod_unblock.success": "Hai sbloccato @{name} dal gruppo", "group.header.alt": "Testata del gruppo", - "group.join": "Entra nel gruppo", + "group.join.public": "Entra nel gruppo", "group.join.request_success": "Richiesta di partecipazione", "group.join.success": "Partecipazione nel gruppo", "group.leave": "Abbandona il gruppo", @@ -747,7 +747,7 @@ "group.moderator_subheading": "Moderazione del gruppo", "group.privacy.locked": "Privato", "group.privacy.public": "Pubblico", - "group.request_join": "Richiesta di partecipazione", + "group.join.private": "Richiesta di partecipazione", "group.role.admin": "Amministrazione", "group.role.moderator": "Moderazione", "group.tabs.all": "Tutto", diff --git a/app/soapbox/locales/pl.json b/app/soapbox/locales/pl.json index 2b32f88dc1..49634bdaff 100644 --- a/app/soapbox/locales/pl.json +++ b/app/soapbox/locales/pl.json @@ -591,13 +591,13 @@ "getting_started.open_source_notice": "{code_name} jest oprogramowaniem o otwartym źródle. Możesz pomóc w rozwoju lub zgłaszać błędy na GitLabie tutaj: {code_link} (v{code_version}).", "group.admin_subheading": "Administratorzy grupy", "group.header.alt": "Nagłówek grupy", - "group.join": "Dołącz do grupy", + "group.join.public": "Dołącz do grupy", "group.leave": "Opuść grupę", "group.manage": "Edytuj grupę", "group.moderator_subheading": "Moderatorzy grupy", "group.privacy.locked": "Prywatna", "group.privacy.public": "Publiczna", - "group.request_join": "Poproś o dołączenie do grupy", + "group.join.private": "Poproś o dołączenie do grupy", "group.role.admin": "Administrator", "group.role.moderator": "Moderator", "group.tabs.all": "Wszystko", diff --git a/app/soapbox/locales/zh-CN.json b/app/soapbox/locales/zh-CN.json index 0fa22a73cb..6257893512 100644 --- a/app/soapbox/locales/zh-CN.json +++ b/app/soapbox/locales/zh-CN.json @@ -738,7 +738,7 @@ "group.group_mod_unblock": "解除屏蔽", "group.group_mod_unblock.success": "已从群组中解除屏蔽 @{name}", "group.header.alt": "群组标题", - "group.join": "加入群组", + "group.join.public": "加入群组", "group.join.request_success": "已请求加入群组", "group.join.success": "已加入群组", "group.leave": "离开群组", @@ -747,7 +747,7 @@ "group.moderator_subheading": "群组监察员", "group.privacy.locked": "私有", "group.privacy.public": "公开", - "group.request_join": "请求加入群组", + "group.join.private": "请求加入群组", "group.role.admin": "管理员", "group.role.moderator": "监察员", "group.tabs.all": "全部", diff --git a/app/soapbox/normalizers/group.ts b/app/soapbox/normalizers/group.ts index 314768304b..e4bf1df6bd 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: '', @@ -127,6 +128,11 @@ const normalizeFqn = (group: ImmutableMap) => { return group.set('fqn', fqn); }; +const normalizeLocked = (group: ImmutableMap) => { + const locked = group.get('locked') || group.get('group_visibility') === 'members_only'; + return group.set('locked', locked); +}; + /** Rewrite `

` to empty string. */ const fixNote = (group: ImmutableMap) => { @@ -144,6 +150,7 @@ export const normalizeGroup = (group: Record) => { normalizeAvatar(group); normalizeHeader(group); normalizeFqn(group); + normalizeLocked(group); fixDisplayName(group); fixNote(group); addInternalFields(group); diff --git a/app/soapbox/pages/group-page.tsx b/app/soapbox/pages/group-page.tsx index d48caafd08..92518a55dd 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 new file mode 100644 index 0000000000..5fb8ed2dc5 --- /dev/null +++ b/app/soapbox/queries/groups.ts @@ -0,0 +1,132 @@ +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 = { + 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, +}; + +const useGroups = () => { + const api = useApi(); + const account = useOwnAccount(); + const dispatch = useAppDispatch(); + const features = useFeatures(); + + const getGroups = async (pageParam?: any): Promise> => { + const endpoint = '/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 && features.groups, + 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/v1/groups/search?q=group'); // '/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, + }; +}; + +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 }; diff --git a/app/soapbox/queries/groups/search.ts b/app/soapbox/queries/groups/search.ts new file mode 100644 index 0000000000..3da166cc22 --- /dev/null +++ b/app/soapbox/queries/groups/search.ts @@ -0,0 +1,67 @@ +import { useInfiniteQuery } from '@tanstack/react-query'; + +import { getNextLink } from 'soapbox/api'; +import { useApi, useFeatures } from 'soapbox/hooks'; +import { normalizeGroup } from 'soapbox/normalizers'; +import { Group } from 'soapbox/types/entities'; +import { flattenPages, PaginatedResult } from 'soapbox/utils/queries'; + +const GroupSearchKeys = { + search: (query?: string) => query ? ['groups', 'search', query] : ['groups', 'search'] as const, +}; + +type PageParam = { + link: string +} + +const useGroupSearch = (search?: string) => { + const api = useApi(); + const features = useFeatures(); + + const getSearchResults = async (pageParam: PageParam): Promise> => { + const nextPageLink = pageParam?.link; + const uri = nextPageLink || '/api/v1/groups/search'; + const response = await api.get(uri, { + params: search ? { + q: search, + } : undefined, + }); + const { data } = response; + + const link = getNextLink(response); + const hasMore = !!link; + const result = data.map(normalizeGroup); + + return { + result, + hasMore, + link, + }; + }; + + const queryInfo = useInfiniteQuery( + GroupSearchKeys.search(search), + ({ pageParam }) => getSearchResults(pageParam), + { + keepPreviousData: true, + enabled: features.groups && !!search, + getNextPageParam: (config) => { + if (config.hasMore) { + return { link: config.link }; + } + + return undefined; + }, + }); + + const data = flattenPages(queryInfo.data); + + return { + ...queryInfo, + groups: data || [], + }; +}; + +export { + useGroupSearch, +}; diff --git a/app/soapbox/settings.ts b/app/soapbox/settings.ts index 70efc0e8e0..e0ee058527 100644 --- a/app/soapbox/settings.ts +++ b/app/soapbox/settings.ts @@ -53,3 +53,6 @@ export const pushNotificationsSetting = new Settings('soapbox_push_notification_ /** Remember hashtag usage. */ export const tagHistory = new Settings('soapbox_tag_history'); + +/** Remember group usage. */ +export const groupSearchHistory = new Settings('soapbox_group_search_history'); diff --git a/app/soapbox/utils/features.ts b/app/soapbox/utils/features.ts index 3b1c14b77f..02459210bc 100644 --- a/app/soapbox/utils/features.ts +++ b/app/soapbox/utils/features.ts @@ -497,7 +497,12 @@ const getInstanceFeatures = (instance: Instance) => { * @see POST /api/v1/admin/groups/:group_id/unsuspend * @see DELETE /api/v1/admin/groups/:group_id */ - groups: false, + groups: v.build === UNRELEASED, + + /** + * Can see trending/suggested Groups. + */ + groupsDiscovery: v.software === TRUTHSOCIAL, /** * Can hide follows/followers lists and counts. diff --git a/app/soapbox/utils/groups.ts b/app/soapbox/utils/groups.ts new file mode 100644 index 0000000000..28264e090b --- /dev/null +++ b/app/soapbox/utils/groups.ts @@ -0,0 +1,35 @@ +import { groupSearchHistory } from 'soapbox/settings'; + +const RECENT_SEARCHES_KEY = 'soapbox:recent-group-searches'; + +const clearRecentGroupSearches = (currentUserId: string) => groupSearchHistory.remove(currentUserId); + +const saveGroupSearch = (currentUserId: string, search: string) => { + let currentSearches: string[] = []; + + if (groupSearchHistory.get(currentUserId)) { + currentSearches = groupSearchHistory.get(currentUserId); + } + + if (currentSearches.indexOf(search) === -1) { + currentSearches.unshift(search); + if (currentSearches.length > 10) { + currentSearches.pop(); + } + + groupSearchHistory.set(currentUserId, currentSearches); + + return currentSearches; + } else { + // The search term has already been searched. Move it to the beginning + // of the cached list. + const indexOfSearch = currentSearches.indexOf(search); + const nextCurrentSearches = [...currentSearches]; + nextCurrentSearches.splice(0, 0, ...nextCurrentSearches.splice(indexOfSearch, 1)); + localStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(nextCurrentSearches)); + + return nextCurrentSearches; + } +}; + +export { clearRecentGroupSearches, saveGroupSearch }; diff --git a/package.json b/package.json index 721aa6dc3a..4e620e5076 100644 --- a/package.json +++ b/package.json @@ -209,6 +209,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 d5c9d5f068..10270a77f9 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 c596e6f57f..32713146e3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3836,6 +3836,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"