Add support for Group search

This commit is contained in:
Chewbacca 2023-02-28 10:26:27 -05:00
parent 0414c36a1e
commit e7897228c6
12 changed files with 545 additions and 8 deletions

View file

@ -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<HTMLDivElement>) => {
@ -84,7 +83,7 @@ const Group = forwardRef((props: IGroup, ref: React.ForwardedRef<HTMLDivElement>
theme='primary'
block
>
Join Group
{group.locked ? 'Request to Join' : 'Join Group'}
</Button>
</div>
);

View file

@ -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 (
<Stack space={4}>
<PlaceholderGroupSearch />
<PlaceholderGroupSearch />
<PlaceholderGroupSearch />
</Stack>
);
}
if (hasNoSearchResults) {
return <NoResultsBlankslate />;
}
if (hasSearchResults) {
return (
<Results
groupSearchResult={groupSearchResult}
/>
);
}
return (
<RecentSearches onSelect={onSelect} />
);
};

View file

@ -0,0 +1,22 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import { Stack, Text } from 'soapbox/components/ui';
export default () => (
<Stack space={2} className='px-4 py-2'>
<Text weight='bold' size='lg' data-testid='no-results'>
<FormattedMessage
id='groups.discover.search.no_results.title'
defaultMessage='No matches found'
/>
</Text>
<Text theme='muted'>
<FormattedMessage
id='groups.discover.search.no_results.subtitle'
defaultMessage='Try searching for another group.'
/>
</Text>
</Stack>
);

View file

@ -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<string[]>(groupSearchHistory.get(me?.id as string) || []);
const onClearRecentSearches = () => {
clearRecentGroupSearches(me?.id as string);
setRecentSearches([]);
};
return (
<Stack space={2}>
{recentSearches.length > 0 ? (
<>
<HStack
alignItems='center'
justifyContent='between'
className='bg-white dark:bg-gray-900'
>
<Text theme='muted' weight='semibold' size='sm'>
Recent searches
</Text>
<button onClick={onClearRecentSearches}>
<Text theme='primary' size='sm' className='hover:underline'>
Clear All
</Text>
</button>
</HStack>
<Virtuoso
useWindowScroll
data={recentSearches}
itemContent={(_index, recentSearch) => (
<div key={recentSearch}>
<button
onClick={() => onSelect(recentSearch)}
className='group flex w-full flex-col rounded-lg p-2 hover:bg-gray-100 dark:hover:bg-gray-800'
>
<HStack alignItems='center' space={2}>
<div className='flex h-10 w-10 items-center justify-center rounded-full bg-gray-200 p-2 dark:bg-gray-800 dark:group-hover:bg-gray-700/20'>
<Icon
src={require('@tabler/icons/hash.svg')}
className='h-5 w-5 text-gray-600'
/>
</div>
<Text weight='bold' size='sm' align='left'>{recentSearch}</Text>
</HStack>
</button>
</div>
)}
/>
</>
) : (
<Stack space={2}>
<Text weight='bold' size='lg' data-testid='no-results'>
<FormattedMessage id='groups.discover.search.blankslate.title' defaultMessage='No recent searches' />
</Text>
<Text theme='muted'>
<FormattedMessage id='groups.discover.search.blankslate.subtitle' defaultMessage='Search group names, topics or keywords' />
</Text>
</Stack>
)}
</Stack>
);
};

View file

@ -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<typeof useGroupSearch>
}
enum Layout {
LIST = 'LIST',
GRID = 'GRID'
}
const GridList: Components['List'] = React.forwardRef((props, ref) => {
const { context, ...rest } = props;
return <div ref={ref} {...rest} className='flex flex-wrap' />;
});
export default (props: Props) => {
const { groupSearchResult } = props;
const [layout, setLayout] = useState<Layout>(Layout.LIST);
const { groups, hasNextPage, isFetching, fetchNextPage } = groupSearchResult;
const handleLoadMore = () => {
if (hasNextPage && !isFetching) {
fetchNextPage();
}
};
const renderGroupList = useCallback((group: Group, index: number) => (
<HStack
alignItems='center'
justifyContent='between'
className={
clsx({
'pt-4': index !== 0,
})
}
>
<HStack alignItems='center' space={2}>
<Avatar
className='ring-2 ring-white dark:ring-primary-900'
src={group.avatar}
size={44}
/>
<Stack>
<Text
weight='bold'
dangerouslySetInnerHTML={{ __html: group.display_name_html }}
/>
<HStack className='text-gray-700 dark:text-gray-600' space={1} alignItems='center'>
<Icon
className='h-4.5 w-4.5'
src={group.locked ? require('@tabler/icons/lock.svg') : require('@tabler/icons/world.svg')}
/>
<Text theme='inherit' tag='span' size='sm' weight='medium'>
{group.locked ? (
<FormattedMessage id='group.privacy.locked' defaultMessage='Private' />
) : (
<FormattedMessage id='group.privacy.public' defaultMessage='Public' />
)}
</Text>
{typeof group.members_count !== 'undefined' && (
<>
<span>&bull;</span>
<Text theme='inherit' tag='span' size='sm' weight='medium'>
{shortNumberFormat(group.members_count)}
{' '}
members
</Text>
</>
)}
</HStack>
</Stack>
</HStack>
<Button theme='primary'>
{group.locked ? 'Request to Join' : 'Join Group'}
</Button>
</HStack>
), []);
const renderGroupGrid = useCallback((group: Group, index: number) => (
<div className='pb-4'>
<GroupComp group={group} />
</div>
), []);
return (
<Stack space={4}>
<HStack alignItems='center' justifyContent='between'>
<Text weight='semibold'>Groups</Text>
<HStack alignItems='center'>
<button onClick={() => setLayout(Layout.LIST)}>
<Icon
src={require('@tabler/icons/layout-list.svg')}
className={
clsx('h-5 w-5 text-gray-600', {
'text-primary-600': layout === Layout.LIST,
})
}
/>
</button>
<button onClick={() => setLayout(Layout.GRID)}>
<Icon
src={require('@tabler/icons/layout-grid.svg')}
className={
clsx('h-5 w-5 text-gray-600', {
'text-primary-600': layout === Layout.GRID,
})
}
/>
</button>
</HStack>
</HStack>
{layout === Layout.LIST ? (
<Virtuoso
useWindowScroll
data={groups}
itemContent={(index, group) => renderGroupList(group, index)}
endReached={handleLoadMore}
/>
) : (
<VirtuosoGrid
useWindowScroll
data={groups}
itemContent={(index, group) => renderGroupGrid(group, index)}
components={{
Item: (props) => (
<div {...props} className='w-1/2 flex-none' />
),
List: GridList,
}}
endReached={handleLoadMore}
/>
)}
</Stack>
);
};

View file

@ -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<boolean>(false);
const [value, setValue] = useState<string>('');
const hasSearchValue = value && value.length > 0;
const cancelSearch = () => {
clearValue();
setIsSearching(false);
};
const clearValue = () => setValue('');
return (
<Stack space={4}>
<TabBar activeTab={TabItems.FIND_GROUPS} />
<Stack space={6}>
<PopularGroups />
<SuggestedGroups />
<HStack alignItems='center'>
{isSearching ? (
<IconButton
src={require('@tabler/icons/arrow-left.svg')}
iconClassName='mr-2 h-5 w-5 fill-current text-gray-600'
onClick={cancelSearch}
/>
) : null}
<Input
data-testid='search'
type='text'
placeholder={intl.formatMessage(messages.placeholder)}
value={value}
onChange={(event) => setValue(event.target.value)}
onFocus={() => setIsSearching(true)}
outerClassName='mt-0 w-full'
theme='search'
append={
<button onClick={clearValue}>
<Icon
src={hasSearchValue ? require('@tabler/icons/x.svg') : require('@tabler/icons/search.svg')}
className='h-4 w-4 text-gray-700 dark:text-gray-600'
aria-hidden='true'
/>
</button>
}
/>
</HStack>
{isSearching ? (
<Search
searchValue={value}
onSelect={(newValue) => setValue(newValue)}
/>
) : (
<>
<PopularGroups />
<SuggestedGroups />
</>
)}
</Stack>
</Stack>
);

View file

@ -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 (
<HStack
alignItems='center'
justifyContent='between'
className='animate-pulse'
>
<HStack alignItems='center' space={2}>
{/* Group Avatar */}
<div className='h-11 w-11 rounded-full bg-gray-500 dark:bg-gray-700 dark:ring-primary-900' />
<Stack className='text-gray-500 dark:text-gray-700'>
<Text theme='inherit' weight='bold'>
{generateText(groupNameLength)}
</Text>
<HStack space={1} alignItems='center'>
<Text theme='inherit' tag='span' size='sm' weight='medium'>
{generateText(6)}
</Text>
<span>&bull;</span>
<Text theme='inherit' tag='span' size='sm' weight='medium'>
{generateText(6)}
</Text>
</HStack>
</Stack>
</HStack>
{/* Join Group Button */}
<div className='h-10 w-36 rounded-full bg-gray-300 dark:bg-gray-800' />
</HStack>
);
};

View file

@ -128,6 +128,11 @@ const normalizeFqn = (group: ImmutableMap<string, any>) => {
return group.set('fqn', fqn);
};
const normalizeLocked = (group: ImmutableMap<string, any>) => {
const locked = group.get('locked') || group.get('group_visibility') === 'members_only';
return group.set('locked', locked);
};
/** Rewrite `<p></p>` to empty string. */
const fixNote = (group: ImmutableMap<string, any>) => {
@ -145,6 +150,7 @@ export const normalizeGroup = (group: Record<string, any>) => {
normalizeAvatar(group);
normalizeHeader(group);
normalizeFqn(group);
normalizeLocked(group);
fixDisplayName(group);
fixNote(group);
addInternalFields(group);

View file

@ -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<PaginatedResult<Group>> => {
const nextPageLink = pageParam?.link;
const uri = nextPageLink || '/api/v1/groups/search';
const response = await api.get<Group[]>(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,
};

View file

@ -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');

View file

@ -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.

View file

@ -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 };