diff --git a/app/soapbox/features/groups/components/discover/group.tsx b/app/soapbox/features/groups/components/discover/group.tsx index dc5d621a4..b7df09b40 100644 --- a/app/soapbox/features/groups/components/discover/group.tsx +++ b/app/soapbox/features/groups/components/discover/group.tsx @@ -5,10 +5,9 @@ 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 + width?: number } const Group = forwardRef((props: IGroup, ref: React.ForwardedRef) => { @@ -84,7 +83,7 @@ const Group = forwardRef((props: IGroup, ref: React.ForwardedRef theme='primary' block > - Join Group + {group.locked ? 'Request to Join' : 'Join Group'} ); diff --git a/app/soapbox/features/groups/components/discover/search/index.tsx b/app/soapbox/features/groups/components/discover/search/index.tsx new file mode 100644 index 000000000..083dab8d5 --- /dev/null +++ b/app/soapbox/features/groups/components/discover/search/index.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/search/no-results-blankslate.tsx b/app/soapbox/features/groups/components/discover/search/no-results-blankslate.tsx new file mode 100644 index 000000000..b4ff0d605 --- /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 000000000..efe0e76fe --- /dev/null +++ b/app/soapbox/features/groups/components/discover/search/recent-searches.tsx @@ -0,0 +1,83 @@ +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 ? ( + <> + + + Recent searches + + + + + + ( +
+ +
+ )} + /> + + ) : ( + + + + + + + + + + )} +
+ ); +}; \ 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 000000000..08494ebdf --- /dev/null +++ b/app/soapbox/features/groups/components/discover/search/results.tsx @@ -0,0 +1,156 @@ +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)} + {' '} + members + + + )} + + + + + + + ), []); + + const renderGroupGrid = useCallback((group: Group, index: number) => ( +
+ +
+ ), []); + + return ( + + + Groups + + + + + + + + + {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/discover.tsx b/app/soapbox/features/groups/discover.tsx index 3145e3a58..4b40be492 100644 --- a/app/soapbox/features/groups/discover.tsx +++ b/app/soapbox/features/groups/discover.tsx @@ -1,19 +1,78 @@ -import React from 'react'; +import React, { useState } from 'react'; +import { defineMessages, useIntl } from 'react-intl'; -import { Stack } from 'soapbox/components/ui'; +import { HStack, Icon, IconButton, Input, Stack } from 'soapbox/components/ui'; import PopularGroups from './components/discover/popular-groups'; +import Search from './components/discover/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)} + /> + ) : ( + <> + + + + )} ); 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 000000000..b2e2dc6f8 --- /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/normalizers/group.ts b/app/soapbox/normalizers/group.ts index 2c94d873d..1fd8c69d9 100644 --- a/app/soapbox/normalizers/group.ts +++ b/app/soapbox/normalizers/group.ts @@ -128,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) => { @@ -145,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/queries/groups/search.ts b/app/soapbox/queries/groups/search.ts new file mode 100644 index 000000000..3da166cc2 --- /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 70efc0e8e..e0ee05852 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 8956411dd..02459210b 100644 --- a/app/soapbox/utils/features.ts +++ b/app/soapbox/utils/features.ts @@ -497,7 +497,7 @@ 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. diff --git a/app/soapbox/utils/groups.ts b/app/soapbox/utils/groups.ts new file mode 100644 index 000000000..28264e090 --- /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 };