Add support for Group search
This commit is contained in:
parent
0414c36a1e
commit
e7897228c6
12 changed files with 545 additions and 8 deletions
|
@ -5,10 +5,9 @@ import { Avatar, Button, HStack, Icon, Stack, Text } from 'soapbox/components/ui
|
||||||
import { Group as GroupEntity } from 'soapbox/types/entities';
|
import { Group as GroupEntity } from 'soapbox/types/entities';
|
||||||
import { shortNumberFormat } from 'soapbox/utils/numbers';
|
import { shortNumberFormat } from 'soapbox/utils/numbers';
|
||||||
|
|
||||||
|
|
||||||
interface IGroup {
|
interface IGroup {
|
||||||
group: GroupEntity
|
group: GroupEntity
|
||||||
width: number
|
width?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const Group = forwardRef((props: IGroup, ref: React.ForwardedRef<HTMLDivElement>) => {
|
const Group = forwardRef((props: IGroup, ref: React.ForwardedRef<HTMLDivElement>) => {
|
||||||
|
@ -84,7 +83,7 @@ const Group = forwardRef((props: IGroup, ref: React.ForwardedRef<HTMLDivElement>
|
||||||
theme='primary'
|
theme='primary'
|
||||||
block
|
block
|
||||||
>
|
>
|
||||||
Join Group
|
{group.locked ? 'Request to Join' : 'Join Group'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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} />
|
||||||
|
);
|
||||||
|
};
|
|
@ -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>
|
||||||
|
);
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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>•</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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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 PopularGroups from './components/discover/popular-groups';
|
||||||
|
import Search from './components/discover/search';
|
||||||
import SuggestedGroups from './components/discover/suggested-groups';
|
import SuggestedGroups from './components/discover/suggested-groups';
|
||||||
import TabBar, { TabItems } from './components/tab-bar';
|
import TabBar, { TabItems } from './components/tab-bar';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
placeholder: { id: 'groups.discover.search.placeholder', defaultMessage: 'Search' },
|
||||||
|
});
|
||||||
|
|
||||||
const Discover: React.FC = () => {
|
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 (
|
return (
|
||||||
<Stack space={4}>
|
<Stack space={4}>
|
||||||
<TabBar activeTab={TabItems.FIND_GROUPS} />
|
<TabBar activeTab={TabItems.FIND_GROUPS} />
|
||||||
|
|
||||||
<Stack space={6}>
|
<Stack space={6}>
|
||||||
<PopularGroups />
|
<HStack alignItems='center'>
|
||||||
<SuggestedGroups />
|
{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>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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>•</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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -128,6 +128,11 @@ const normalizeFqn = (group: ImmutableMap<string, any>) => {
|
||||||
return group.set('fqn', fqn);
|
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. */
|
/** Rewrite `<p></p>` to empty string. */
|
||||||
const fixNote = (group: ImmutableMap<string, any>) => {
|
const fixNote = (group: ImmutableMap<string, any>) => {
|
||||||
|
@ -145,6 +150,7 @@ export const normalizeGroup = (group: Record<string, any>) => {
|
||||||
normalizeAvatar(group);
|
normalizeAvatar(group);
|
||||||
normalizeHeader(group);
|
normalizeHeader(group);
|
||||||
normalizeFqn(group);
|
normalizeFqn(group);
|
||||||
|
normalizeLocked(group);
|
||||||
fixDisplayName(group);
|
fixDisplayName(group);
|
||||||
fixNote(group);
|
fixNote(group);
|
||||||
addInternalFields(group);
|
addInternalFields(group);
|
||||||
|
|
67
app/soapbox/queries/groups/search.ts
Normal file
67
app/soapbox/queries/groups/search.ts
Normal 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,
|
||||||
|
};
|
|
@ -53,3 +53,6 @@ export const pushNotificationsSetting = new Settings('soapbox_push_notification_
|
||||||
|
|
||||||
/** Remember hashtag usage. */
|
/** Remember hashtag usage. */
|
||||||
export const tagHistory = new Settings('soapbox_tag_history');
|
export const tagHistory = new Settings('soapbox_tag_history');
|
||||||
|
|
||||||
|
/** Remember group usage. */
|
||||||
|
export const groupSearchHistory = new Settings('soapbox_group_search_history');
|
||||||
|
|
|
@ -497,7 +497,7 @@ const getInstanceFeatures = (instance: Instance) => {
|
||||||
* @see POST /api/v1/admin/groups/:group_id/unsuspend
|
* @see POST /api/v1/admin/groups/:group_id/unsuspend
|
||||||
* @see DELETE /api/v1/admin/groups/:group_id
|
* @see DELETE /api/v1/admin/groups/:group_id
|
||||||
*/
|
*/
|
||||||
groups: false,
|
groups: v.build === UNRELEASED,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Can see trending/suggested Groups.
|
* Can see trending/suggested Groups.
|
||||||
|
|
35
app/soapbox/utils/groups.ts
Normal file
35
app/soapbox/utils/groups.ts
Normal 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 };
|
Loading…
Reference in a new issue