diff --git a/packages/i18n/locales/en/translation.json b/packages/i18n/locales/en/translation.json index 9b9ea341f..4fb5a3bcd 100644 --- a/packages/i18n/locales/en/translation.json +++ b/packages/i18n/locales/en/translation.json @@ -1005,6 +1005,7 @@ "appsProposalDescription": "Add a title and description, and then review the actions generated by the app. Once it all looks good, publish it to the DAO for voting.", "areTheyMissingQuestion": "Are they missing?", "areYouSureConfirmButton": "Are you sure you want to do that? Press again to confirm.", + "atLeastNumDaosFound": "at least {{count, number}} DAOs found", "authzAuthorizationDescription": "Grant / revoke authorizations that allow other accounts to perform actions on behalf of the DAO.", "authzExecDescription": "Perform an action on behalf of another account.", "authzWarning": "USE WITH CAUTION! Granting an Authorization allows another account to perform actions on behalf of your account.", @@ -1279,6 +1280,8 @@ "nothingHereYet": "Nothing here yet.", "notificationsInInbox_one": "There is {{count}} notification in your inbox.", "notificationsInInbox_other": "There are {{count}} notification in your inbox.", + "numDaosFound_one": "{{count, number}} DAO found", + "numDaosFound_other": "{{count, number}} DAOs found", "numNftsSelected_one": "{{count}} NFT selected", "numNftsSelected_other": "{{count}} NFTs selected", "numNftsSelected_zero": "No NFT selected", @@ -1414,6 +1417,7 @@ "searchDraftPlaceholder": "Find a draft...", "searchForAccount": "Search for an account...", "searchForChain": "Search for a chain...", + "searchForDao": "Search for a DAO...", "searchForToken": "Search for a token...", "searchForWidget": "Search for a widget...", "searchMessages": "Search messages", @@ -1704,6 +1708,7 @@ "addToProfile": "Add to Profile", "advancedConfiguration": "Advanced configuration", "all": "All", + "allDaos": "All DAOs", "allNfts": "All NFTs", "approval": "Approval", "approved": "Approved", diff --git a/packages/state/indexer/search.ts b/packages/state/indexer/search.ts index ccbf7b303..124706032 100644 --- a/packages/state/indexer/search.ts +++ b/packages/state/indexer/search.ts @@ -1,4 +1,4 @@ -import MeiliSearch from 'meilisearch' +import MeiliSearch, { SearchResponse } from 'meilisearch' import { IndexerDumpState, WithChainId } from '@dao-dao/types' import { ProposalResponse as MultipleChoiceProposalResponse } from '@dao-dao/types/contracts/DaoProposalMultiple' @@ -37,17 +37,49 @@ export type DaoSearchResult = { } export type SearchDaosOptions = WithChainId<{ + /** + * Search query that compares against all fields. + */ query: string + /** + * Offset to start search results from. + */ + offset?: number + /** + * Limit number of search results. + */ limit?: number + /** + * Number of hits per page. + */ + hitsPerPage?: number + /** + * Page number. + */ + page?: number + /** + * Exclude DAOs by their core address. + */ exclude?: string[] + /** + * Sort search results by the given fields. + * + * Defaults to most recently created at the top, and deprioritize those with + * no proposals: `['value.createdAtEpoch:desc', 'value.proposalCount:desc']`. + */ + sort?: string[] }> export const searchDaos = async ({ chainId, query, + offset, limit, + hitsPerPage, + page, exclude = [], -}: SearchDaosOptions): Promise => { + sort = ['value.createdAtEpoch:desc', 'value.proposalCount:desc'], +}: SearchDaosOptions): Promise> => { const client = await loadMeilisearchClient() if (!chainIsIndexed(chainId)) { @@ -59,7 +91,10 @@ export const searchDaos = async ({ exclude.push(...DAOS_HIDDEN_FROM_SEARCH) const results = await index.search>(query, { + offset, limit, + hitsPerPage, + page, filter: [ // Exclude inactive DAOs. `NOT value.config.name IN ["${INACTIVE_DAO_NAMES.join('", "')}"]`, @@ -70,14 +105,16 @@ export const searchDaos = async ({ ] .map((filter) => `(${filter})`) .join(' AND '), - // Most recent at the top. - sort: ['block.height:desc', 'value.proposalCount:desc'], + sort, }) - return results.hits.map((hit) => ({ - chainId, - ...hit, - })) + return { + ...results, + hits: results.hits.map((hit) => ({ + chainId, + ...hit, + })), + } } export type DaoProposalSearchResult = { diff --git a/packages/state/query/queries/indexer.ts b/packages/state/query/queries/indexer.ts index 47dcb3e0c..6051ac55f 100644 --- a/packages/state/query/queries/indexer.ts +++ b/packages/state/query/queries/indexer.ts @@ -5,9 +5,11 @@ import { IndexerFormulaType } from '@dao-dao/types' import { QueryIndexerOptions, QuerySnapperOptions, + SearchDaosOptions, queryIndexer, queryIndexerUpStatus, querySnapper, + searchDaos, } from '../../indexer' /** @@ -146,4 +148,12 @@ export const indexerQueries = { // props can't serialize undefined. queryFn: async () => (await querySnapper(options)) ?? null, }), + /** + * Search DAOs. + */ + searchDaos: (options: SearchDaosOptions) => + queryOptions({ + queryKey: ['indexer', 'searchDaos', options], + queryFn: () => searchDaos(options), + }), } diff --git a/packages/state/recoil/selectors/indexer.ts b/packages/state/recoil/selectors/indexer.ts index 92b7c8d84..e7798fab4 100644 --- a/packages/state/recoil/selectors/indexer.ts +++ b/packages/state/recoil/selectors/indexer.ts @@ -17,17 +17,14 @@ import { import { DaoProposalSearchResult, - DaoSearchResult, QueryIndexerOptions, QuerySnapperOptions, SearchDaoProposalsOptions, - SearchDaosOptions, loadMeilisearchClient, queryIndexer, queryIndexerUpStatus, querySnapper, searchDaoProposals, - searchDaos, } from '../../indexer' import { refreshIndexerUpStatusAtom, @@ -211,14 +208,6 @@ export const querySnapperSelector = selectorFamily({ get: (options) => async () => await querySnapper(options), }) -export const searchDaosSelector = selectorFamily< - DaoSearchResult[], - SearchDaosOptions ->({ - key: 'searchDaos', - get: (options) => async () => await searchDaos(options), -}) - /** * Get recent DAO proposals for a chain. */ diff --git a/packages/stateful/command/hooks/useFollowingAndFilteredDaosSections.ts b/packages/stateful/command/hooks/useFollowingAndFilteredDaosSections.ts index 735bdec23..c3cbaa046 100644 --- a/packages/stateful/command/hooks/useFollowingAndFilteredDaosSections.ts +++ b/packages/stateful/command/hooks/useFollowingAndFilteredDaosSections.ts @@ -1,8 +1,10 @@ +import { useQueries } from '@tanstack/react-query' import { useTranslation } from 'react-i18next' -import { useRecoilValue, waitForAllSettled } from 'recoil' +import { useRecoilValue } from 'recoil' -import { navigatingToHrefAtom, searchDaosSelector } from '@dao-dao/state/recoil' -import { useCachedLoadable, useDaoNavHelpers } from '@dao-dao/stateless' +import { indexerQueries } from '@dao-dao/state/query' +import { navigatingToHrefAtom } from '@dao-dao/state/recoil' +import { useDaoNavHelpers } from '@dao-dao/stateless' import { CommandModalContextSection, CommandModalContextSectionItem, @@ -16,6 +18,7 @@ import { getFallbackImage, getImageUrlForChainId, getSupportedChains, + makeCombineQueryResultsIntoLoadingData, mustGetConfiguredChainConfig, parseContractVersion, } from '@dao-dao/utils' @@ -45,36 +48,32 @@ export const useFollowingAndFilteredDaosSections = ({ const followingDaosLoading = useLoadingFollowingDaos() const { getDaoPath } = useDaoNavHelpers() - const queryResults = useCachedLoadable( - options.filter - ? waitForAllSettled( - chains.map(({ chain }) => - searchDaosSelector({ - chainId: chain.chainId, - query: options.filter, - limit, - // Exclude following DAOs from search since they show in a - // separate section. - exclude: followingDaosLoading.loading - ? undefined - : followingDaosLoading.data - .filter(({ chainId }) => chainId === chain.chainId) - .map(({ coreAddress }) => coreAddress), - }) - ) - ) - : undefined - ) + const searchQueries = useQueries({ + queries: chains.map(({ chain }) => + indexerQueries.searchDaos({ + chainId: chain.chainId, + query: options.filter, + limit, + // Exclude following DAOs from search since they show in a + // separate section. + exclude: followingDaosLoading.loading + ? undefined + : followingDaosLoading.data + .filter(({ chainId }) => chainId === chain.chainId) + .map(({ coreAddress }) => coreAddress), + }) + ), + combine: makeCombineQueryResultsIntoLoadingData({ + transform: (results) => results.flatMap((r) => r.hits), + }), + }) const navigatingToHref = useRecoilValue(navigatingToHrefAtom) // Use query results if filter is present. const daos = [ ...(options.filter - ? (queryResults.state !== 'hasValue' - ? [] - : queryResults.contents.flatMap((l) => l.valueMaybe() || []) - ) + ? (searchQueries.loading ? [] : searchQueries.data) .filter(({ value }) => !!value?.config) .map( ({ @@ -142,8 +141,7 @@ export const useFollowingAndFilteredDaosSections = ({ // When filter present, use search results. Otherwise use featured DAOs. const daosLoading = options.filter - ? queryResults.state === 'loading' || - (queryResults.state === 'hasValue' && queryResults.updating) + ? searchQueries.loading || searchQueries.updating : featuredDaosLoading.loading || !!featuredDaosLoading.updating const followingSection: CommandModalContextSection = { diff --git a/packages/stateful/components/AddressInput.tsx b/packages/stateful/components/AddressInput.tsx index 390b27185..56cee7f5c 100644 --- a/packages/stateful/components/AddressInput.tsx +++ b/packages/stateful/components/AddressInput.tsx @@ -3,14 +3,11 @@ import Fuse from 'fuse.js' import { useMemo } from 'react' import { FieldValues, Path, useFormContext } from 'react-hook-form' import { useTranslation } from 'react-i18next' -import { waitForNone } from 'recoil' import { useDeepCompareMemoize } from 'use-deep-compare-effect' -import { profileQueries } from '@dao-dao/state/query' -import { searchDaosSelector } from '@dao-dao/state/recoil' +import { indexerQueries, profileQueries } from '@dao-dao/state/query' import { AddressInput as StatelessAddressInput, - useCachedLoadable, useChain, } from '@dao-dao/stateless' import { AddressInputProps, Entity, EntityType } from '@dao-dao/types' @@ -58,10 +55,10 @@ export const AddressInput = < // Search DAOs on current chains and all polytone-connected chains so we can // find polytone accounts. - const searchDaosLoadable = useCachedLoadable( - hasFormValue && props.type !== 'wallet' - ? waitForNone( - [ + const searchedDaos = useQueries({ + queries: + hasFormValue && props.type !== 'wallet' + ? [ // Current chain. currentChain.chainId, // Chains that have polytone connections with the current chain. @@ -69,15 +66,18 @@ export const AddressInput = < Object.keys(destChains).includes(currentChain.chainId) ).map(([chainId]) => chainId), ].map((chainId) => - searchDaosSelector({ + indexerQueries.searchDaos({ chainId, query: formValue, limit: 5, }) ) - ) - : undefined - ) + : [], + combine: makeCombineQueryResultsIntoLoadingData({ + firstLoad: 'none', + transform: (results) => results.flatMap((r) => r.hits), + }), + }) const queryClient = useQueryClient() const loadingEntities = useQueries({ @@ -90,16 +90,12 @@ export const AddressInput = < }) ) : []), - ...(searchDaosLoadable.state === 'hasValue' - ? searchDaosLoadable.contents.flatMap((loadable) => - loadable.state === 'hasValue' - ? loadable.contents.map(({ chainId, id: address }) => - entityQueries.info(queryClient, { - chainId, - address, - }) - ) - : [] + ...(!searchedDaos.loading + ? searchedDaos.data.flatMap(({ chainId, id: address }) => + entityQueries.info(queryClient, { + chainId, + address, + }) ) : []), ], @@ -151,12 +147,7 @@ export const AddressInput = < (!searchProfilesLoading.errored && searchProfilesLoading.updating))) || (props.type !== 'wallet' && - (searchDaosLoadable.state === 'loading' || - (searchDaosLoadable.state === 'hasValue' && - (searchDaosLoadable.updating || - searchDaosLoadable.contents.some( - (loadable) => loadable.state === 'loading' - ))))) || + (searchedDaos.loading || searchedDaos.updating)) || loadingEntities.loading || !!loadingEntities.updating, } diff --git a/packages/stateful/components/pages/Home.tsx b/packages/stateful/components/pages/Home.tsx index 07aa78ca0..61e990406 100644 --- a/packages/stateful/components/pages/Home.tsx +++ b/packages/stateful/components/pages/Home.tsx @@ -1,5 +1,4 @@ -import { DehydratedState } from '@tanstack/react-query' -import clsx from 'clsx' +import { DehydratedState, useInfiniteQuery } from '@tanstack/react-query' import { NextPage } from 'next' import { NextSeo } from 'next-seo' import { useRouter } from 'next/router' @@ -7,24 +6,34 @@ import { useCallback, useEffect } from 'react' import { useTranslation } from 'react-i18next' import { useSetRecoilState } from 'recoil' -import { commandModalVisibleAtom, walletChainIdAtom } from '@dao-dao/state' +import { + SearchDaosOptions, + commandModalVisibleAtom, + indexerQueries, + walletChainIdAtom, +} from '@dao-dao/state' import { ChainPickerPopup, Logo, Home as StatelessHome, + useInfiniteScroll, + useQuerySyncedState, } from '@dao-dao/stateless' import { DaoDaoIndexerAllStats, DaoInfo, DaoSource, + LazyDaoCardProps, LoadingData, + LoadingDataWithError, StatefulDaoCardProps, } from '@dao-dao/types' import { SITE_TITLE, SITE_URL, - UNDO_PAGE_PADDING_TOP_CLASSES, + getFallbackImage, getSupportedChainConfig, + parseContractVersion, } from '@dao-dao/utils' import { @@ -32,7 +41,7 @@ import { useLoadingFeaturedDaoCards, useWallet, } from '../../hooks' -import { DaoCard } from '../dao' +import { DaoCard, LazyDaoCard } from '../dao' import { LinkWrapper } from '../LinkWrapper' import { PageHeaderContent } from '../PageHeaderContent' import { ProfileHome } from './ProfileHome' @@ -83,6 +92,84 @@ export const Home: NextPage = ({ [setCommandModalVisible] ) + const [search, setSearch] = useQuerySyncedState({ + param: 'q', + defaultValue: '', + }) + const searchOptions: SearchDaosOptions = { + chainId: chainId || '', + query: search, + hitsPerPage: 18, + } + const searchQuery = useInfiniteQuery({ + queryKey: indexerQueries.searchDaos(searchOptions).queryKey, + queryFn: (ctx) => { + const { queryFn } = indexerQueries.searchDaos({ + ...searchOptions, + page: ctx.pageParam, + }) + return typeof queryFn === 'function' ? queryFn(ctx) : queryFn + }, + enabled: !!chainId, + initialPageParam: 1, + getNextPageParam: (lastPage) => + lastPage && + typeof lastPage === 'object' && + lastPage.totalPages !== undefined && + lastPage.page !== undefined + ? lastPage.totalPages > lastPage.page + ? lastPage.page + 1 + : undefined + : undefined, + }) + const totalHits = + searchQuery.data && + searchQuery.data.pages.length && + typeof searchQuery.data.pages[0] === 'object' && + typeof searchQuery.data.pages[0].totalHits === 'number' + ? searchQuery.data.pages[0].totalHits + : undefined + const infiniteScrollOptions = useInfiniteScroll({ + loadMore: searchQuery.fetchNextPage, + disabled: !chainId || !searchQuery.hasNextPage || searchQuery.isFetching, + }) + const searchedDaos: LoadingDataWithError = + searchQuery.isPending + ? { loading: true, errored: false } + : searchQuery.isError + ? { loading: false, errored: true, error: searchQuery.error } + : { + loading: false, + errored: false, + updating: searchQuery.isRefetching, + data: + searchQuery.data?.pages.flatMap((page) => + page && typeof page === 'object' + ? page.hits + .filter(({ value }) => !!value?.config) + .flatMap( + ({ + chainId, + id, + value: { + config, + version: { version }, + }, + }): LazyDaoCardProps => ({ + info: { + chainId, + coreAddress: id, + coreVersion: parseContractVersion(version), + name: config.name, + description: config.description, + imageUrl: config.image_url || getFallbackImage(id), + }, + }) + ) + : [] + ) ?? [], + } + const selectedChain = chainId ? getSupportedChainConfig(chainId) : undefined const selectedChainHasSubDaos = !!selectedChain?.subDaos?.length const chainSubDaos = useLoadingDaos( @@ -223,15 +310,26 @@ export const Home: NextPage = ({ } /> -
- -
+ )} diff --git a/packages/stateless/hooks/useOnScreen.ts b/packages/stateless/hooks/useOnScreen.ts index 4267378c3..e00c05e86 100644 --- a/packages/stateless/hooks/useOnScreen.ts +++ b/packages/stateless/hooks/useOnScreen.ts @@ -7,19 +7,20 @@ import { useEffect, useState } from 'react' */ export const useOnScreen = (element: HTMLElement | null) => { const [isOnScreen, setIsOnScreen] = useState(false) - const [observer] = useState( - () => - new IntersectionObserver(([entry]) => setIsOnScreen(entry.isIntersecting)) - ) useEffect(() => { - if (!element) { + if (!element || typeof window === 'undefined') { return } + const observer = new IntersectionObserver(([entry]) => + setIsOnScreen(entry.isIntersecting) + ) + observer.observe(element) + return () => observer.disconnect() - }, [observer, element]) + }, [element]) return isOnScreen } diff --git a/packages/stateless/pages/Home.tsx b/packages/stateless/pages/Home.tsx index 2a2ede02f..52b854490 100644 --- a/packages/stateless/pages/Home.tsx +++ b/packages/stateless/pages/Home.tsx @@ -6,6 +6,7 @@ import { PeopleOutlined, Public, Search, + WarningRounded, } from '@mui/icons-material' import clsx from 'clsx' import { ComponentType, useState } from 'react' @@ -13,17 +14,28 @@ import { useTranslation } from 'react-i18next' import { DaoDaoIndexerAllStats, + LazyDaoCardProps, LoadingData, + LoadingDataWithError, StatefulDaoCardProps, } from '@dao-dao/types' -import { UNDO_PAGE_PADDING_HORIZONTAL_CLASSES } from '@dao-dao/utils' +import { + UNDO_PAGE_PADDING_HORIZONTAL_CLASSES, + UNDO_PAGE_PADDING_TOP_CLASSES, +} from '@dao-dao/utils' import { Button, + DaoCardLoader, DaoInfoCards, + ErrorPage, + GridCardContainer, HorizontalScroller, + NoContent, + SearchBar, SegmentedControls, } from '../components' +import { UseInfiniteScrollReturn } from '../hooks' export type HomeProps = { /** @@ -34,14 +46,49 @@ export type HomeProps = { * The DAO card to render. */ DaoCard: ComponentType + /** + * Optionally show chain x/gov DAO cards. + */ + chainGovDaos?: LoadingData /** * Featured DAO cards to display on the home page. */ featuredDaos: LoadingData /** - * Optionally show chain x/gov DAO cards. + * DAO search stuff. + * + * Only defined if search should be shown. */ - chainGovDaos?: LoadingData + search?: { + /** + * The lazy DAO card to render. + */ + LazyDaoCard: ComponentType + /** + * DAOs to show in searchable list. + */ + searchedDaos: LoadingDataWithError + /** + * Whether or not there are more DAOs to load. + */ + hasMore: boolean + /** + * Count of hits found. + */ + totalHits?: number + /** + * Search query. + * + * Only defined if search should be shown. + */ + query: string + /** + * Function to set the search query. + * + * Only defined if search should be shown. + */ + setQuery: (query: string) => void + } & UseInfiniteScrollReturn /** * Function to open the search modal. */ @@ -55,6 +102,7 @@ export const Home = ({ DaoCard, chainGovDaos, featuredDaos, + search, openSearch, }: HomeProps) => { const { t } = useTranslation() @@ -62,9 +110,14 @@ export const Home = ({ const [statsMode, setStatsMode] = useState('all') return ( - <> +
- className="w-max mb-4" + className="w-max" onSelect={setStatsMode} selected={statsMode} tabs={[ @@ -136,14 +189,14 @@ export const Home = ({ ] : []), ]} - className="mb-8" + className="-mt-4" valueClassName="text-text-interactive-valid font-semibold font-mono" wrap /> {/* Chain governance DAOs */} {chainGovDaos && ( -
+

{t('title.chainGovernance')}

@@ -178,39 +231,99 @@ export const Home = ({ )} {/* Featured DAOs */} -
-
-

{t('title.featuredDaos')}

- - + variant="none" + > + +

+ {t('button.findAnotherDao')} +

+ +
+ + 0) && + UNDO_PAGE_PADDING_HORIZONTAL_CLASSES + )} + contentContainerClassName="px-6" + itemClassName="w-64" + items={featuredDaos} + shadowClassName="w-6" + />
+ )} + + {/* DAO search */} + {search && ( +
+

{t('title.allDaos')}

+ + search.setQuery(e.currentTarget.value)} + placeholder={t('info.searchForDao')} + value={search.query} + /> + + {search.searchedDaos.errored ? ( + + ) : search.searchedDaos.loading || + search.searchedDaos.data.length > 0 ? ( + <> + {!!search.totalHits && ( +

+ {search.totalHits === 1000 + ? t('info.atLeastNumDaosFound', { + count: search.totalHits, + }) + : t('info.numDaosFound', { + count: search.totalHits, + })} +

+ )} - 0) && - UNDO_PAGE_PADDING_HORIZONTAL_CLASSES + + {search.searchedDaos.loading ? ( + [...Array(3)].map((_, index) => ) + ) : ( + <> + {search.searchedDaos.data.map((props) => ( + + ))} + {search.hasMore && + [...Array(3)].map((_, index) => ( + + ))} + + )} + + + ) : ( + )} - contentContainerClassName="px-6" - itemClassName="w-64" - items={featuredDaos} - shadowClassName="w-6" - /> -
- +
+ )} +
) }