diff --git a/packages/shared/src/components/PostsSearch.tsx b/packages/shared/src/components/PostsSearch.tsx index 221f1464c25..f552f2d8dd0 100644 --- a/packages/shared/src/components/PostsSearch.tsx +++ b/packages/shared/src/components/PostsSearch.tsx @@ -22,10 +22,16 @@ import { PopoverContent } from './popover/Popover'; import { SearchIcon } from './icons'; import { StaleTime } from '../lib/query'; +const SEARCH_TYPES = { + searchPostSuggestions: SEARCH_POST_SUGGESTIONS, + searchBookmarksSuggestions: SEARCH_BOOKMARKS_SUGGESTIONS, + searchReadingHistorySuggestions: SEARCH_READING_HISTORY_SUGGESTIONS, +}; + export type PostsSearchProps = { initialQuery?: string; placeholder?: string; - suggestionType?: string; + suggestionType?: keyof typeof SEARCH_TYPES; autoFocus?: boolean; className?: string; onSubmitQuery: ( @@ -35,14 +41,15 @@ export type PostsSearchProps = { }, ) => Promise; onClearQuery?: () => Promise; + /** + * Autocomplete suggestions (Mimir-backed) are not scoped to the current + * feed/source. Surfaces that search within a narrower scope (e.g. a single + * squad) should opt out rather than show suggestions from the wrong scope. + * Defaults to `true` to preserve existing behavior. + */ + enableSuggestions?: boolean; } & Pick, 'onFocus'>; -const SEARCH_TYPES = { - searchPostSuggestions: SEARCH_POST_SUGGESTIONS, - searchBookmarksSuggestions: SEARCH_BOOKMARKS_SUGGESTIONS, - searchReadingHistorySuggestions: SEARCH_READING_HISTORY_SUGGESTIONS, -}; - export default function PostsSearch({ initialQuery: initialQueryProp, autoFocus = true, @@ -52,14 +59,18 @@ export default function PostsSearch({ suggestionType = 'searchPostSuggestions', onFocus, onClearQuery, + enableSuggestions = true, }: PostsSearchProps): ReactElement { const { time, contentCurationFilter } = useSearchContextProvider(); - const searchBoxRef = useRef(); + const searchBoxRef = useRef(null); const [query, setQuery] = useState(); const [items, setItems] = useState([]); const { value: searchVersion } = useConditionalFeature({ feature: feature.searchVersion, - shouldEvaluate: !!query && suggestionType === 'searchPostSuggestions', + shouldEvaluate: + !!query && + suggestionType === 'searchPostSuggestions' && + enableSuggestions, }); const SEARCH_URL = SEARCH_TYPES[suggestionType]; const purify = useDomPurify(); @@ -72,7 +83,7 @@ export default function PostsSearch({ queryKey: [suggestionType, query], queryFn: () => gqlClient.request(SEARCH_URL, { query, version: searchVersion }), - enabled: !!query, + enabled: !!query && enableSuggestions, staleTime: StaleTime.Default, }); @@ -88,7 +99,7 @@ export default function PostsSearch({ const submitQuery = async (item?: string) => { const itemQuery = item?.replace?.(sanitizeSearchTitleMatch, ''); - await onSubmitQuery(itemQuery || query, { + await onSubmitQuery(itemQuery || query || '', { filters: { time: time.toString(), contentCuration: contentCurationFilter, @@ -126,7 +137,7 @@ export default function PostsSearch({ useEffect(() => { if (autoFocus) { - searchBoxRef.current?.querySelector('input').focus(); + searchBoxRef.current?.querySelector('input')?.focus(); } }, [searchBoxRef, autoFocus]); diff --git a/packages/shared/src/components/squads/SquadFeedHeading.tsx b/packages/shared/src/components/squads/SquadFeedHeading.tsx index 0a3d60ebfd2..bdd1c3b1020 100644 --- a/packages/shared/src/components/squads/SquadFeedHeading.tsx +++ b/packages/shared/src/components/squads/SquadFeedHeading.tsx @@ -1,4 +1,4 @@ -import type { ReactElement } from 'react'; +import type { ReactElement, ReactNode } from 'react'; import React, { useContext, useMemo } from 'react'; import { Button, ButtonVariant } from '../buttons/Button'; import { PinIcon } from '../icons'; @@ -8,9 +8,14 @@ import { useSquadActions } from '../../hooks'; interface SquadFeedHeadingProps { squad: Squad; + /** Optional search field rendered at the start of the heading row. */ + searchChildren?: ReactNode; } -function SquadFeedHeading({ squad }: SquadFeedHeadingProps): ReactElement { +function SquadFeedHeading({ + squad, + searchChildren, +}: SquadFeedHeadingProps): ReactElement { const { items } = useContext(ActiveFeedContext); const { collapseSquadPinnedPosts, expandSquadPinnedPosts } = useSquadActions({ squad, @@ -20,9 +25,17 @@ function SquadFeedHeading({ squad }: SquadFeedHeadingProps): ReactElement { const isSquadMember = !!squad.currentMember; const onClick = async () => { - return collapsePinnedPosts - ? await expandSquadPinnedPosts(squad.id) - : await collapseSquadPinnedPosts(squad.id); + const togglePinnedPosts = collapsePinnedPosts + ? expandSquadPinnedPosts + : collapseSquadPinnedPosts; + + if (!togglePinnedPosts || !squad.id) { + throw new Error( + 'SquadFeedHeading: pinned posts toggle requires a squad id and mutation', + ); + } + + return togglePinnedPosts(squad.id); }; const pinnedPostsCount = useMemo( @@ -38,8 +51,22 @@ function SquadFeedHeading({ squad }: SquadFeedHeadingProps): ReactElement { return (
- - {isSquadMember && ( + {/* Mirrors the bookmarks CustomFeedHeader idiom: the search field fills + the row and the action buttons sit directly after it, so there is no + dead gap between the two. */} + {searchChildren && ( +
+ {searchChildren} +
+ )} + {isSquadMember && ( +
); } diff --git a/packages/shared/src/graphql/feed.ts b/packages/shared/src/graphql/feed.ts index f904eee7dd8..d47bb460688 100644 --- a/packages/shared/src/graphql/feed.ts +++ b/packages/shared/src/graphql/feed.ts @@ -680,6 +680,30 @@ export const SEARCH_BOOKMARKS_QUERY = gql` ${FEED_POST_CONNECTION_FRAGMENT} `; +export const SEARCH_SOURCE_POSTS_QUERY = gql` + query SearchSourcePosts( + $loggedIn: Boolean! = false + $first: Int + $after: String + $source: ID! + $query: String! + $supportedTypes: [String!] + $version: Int + ) { + page: searchSourcePosts( + first: $first + after: $after + source: $source + query: $query + supportedTypes: $supportedTypes + version: $version + ) { + ...FeedPostConnection + } + } + ${FEED_POST_CONNECTION_FRAGMENT} +`; + export const SEARCH_BOOKMARKS_SUGGESTIONS = gql` query SearchBookmarksSuggestions($query: String!) { searchBookmarksSuggestions(query: $query) { diff --git a/packages/shared/src/lib/query.ts b/packages/shared/src/lib/query.ts index 70830b11a98..0a980337583 100644 --- a/packages/shared/src/lib/query.ts +++ b/packages/shared/src/lib/query.ts @@ -47,6 +47,7 @@ export enum OtherFeedPage { BookmarkLater = 'bookmarkslater', BookmarkFolder = 'bookmarks[folderId]', SearchBookmarks = 'search-bookmarks', + SearchSquad = 'search-squad', Preview = 'preview', Author = 'author', UserUpvoted = 'user-upvoted', diff --git a/packages/webapp/pages/squads/[handle]/index.tsx b/packages/webapp/pages/squads/[handle]/index.tsx index 0fd1e345330..c5e17e6a5fb 100644 --- a/packages/webapp/pages/squads/[handle]/index.tsx +++ b/packages/webapp/pages/squads/[handle]/index.tsx @@ -1,15 +1,26 @@ import type { GetServerSidePropsContext, GetServerSidePropsResult } from 'next'; import type { ParsedUrlQuery } from 'querystring'; import type { ReactElement } from 'react'; -import React, { useEffect, useMemo, useRef, useState } from 'react'; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import type { NextSeoProps } from 'next-seo'; import Head from 'next/head'; import Feed from '@dailydotdev/shared/src/components/Feed'; +import type { FeedProps } from '@dailydotdev/shared/src/components/Feed'; import { BellIcon } from '@dailydotdev/shared/src/components/icons'; import { + SEARCH_SOURCE_POSTS_QUERY, SOURCE_FEED_QUERY, supportedTypesForPrivateSources, } from '@dailydotdev/shared/src/graphql/feed'; +import { SearchProviderEnum } from '@dailydotdev/shared/src/graphql/search'; +import { useFeaturesReadyContext } from '@dailydotdev/shared/src/components/GrowthBookProvider'; +import { feature } from '@dailydotdev/shared/src/lib/featureManagement'; import { useAuthContext } from '@dailydotdev/shared/src/contexts/AuthContext'; import { SquadPageHeader } from '@dailydotdev/shared/src/components/squads/SquadPageHeader'; import { SquadHeaderBar } from '@dailydotdev/shared/src/components/squads/SquadHeaderBar'; @@ -98,6 +109,21 @@ const SquadEmptyScreen = dynamic( ), ); +const SearchEmptyScreen = dynamic( + () => + import( + /* webpackChunkName: "searchEmptyScreen" */ '@dailydotdev/shared/src/components/SearchEmptyScreen' + ), +); + +const PostsSearch = dynamic( + () => + import( + /* webpackChunkName: "postsSearch" */ '@dailydotdev/shared/src/components/PostsSearch' + ), + { ssr: false }, +); + const SquadLoading = dynamic( () => import( @@ -360,6 +386,101 @@ const SquadPage = ({ [squadId], ); + const searchQuery = + typeof router.query?.q === 'string' ? router.query.q.trim() : ''; + const isSearching = searchQuery.length > 0; + const { getFeatureValue } = useFeaturesReadyContext(); + + const feedProps = useMemo>(() => { + if (isSearching) { + return { + feedName: OtherFeedPage.SearchSquad, + feedQueryKey: [ + 'searchSourcePosts', + user?.id ?? 'anonymous', + squadId, + searchQuery, + ], + query: SEARCH_SOURCE_POSTS_QUERY, + variables: { + source: squadId, + query: searchQuery, + supportedTypes: supportedTypesForPrivateSources, + version: getFeatureValue(feature.searchVersion), + }, + emptyScreen: , + }; + } + + return { + feedName: OtherFeedPage.Squads, + feedQueryKey: [ + 'sourceFeed', + user?.id ?? 'anonymous', + Object.values(queryVariables), + ], + query: SOURCE_FEED_QUERY, + variables: queryVariables, + emptyScreen: , + }; + }, [ + isSearching, + searchQuery, + squadId, + queryVariables, + user?.id, + getFeatureValue, + ]); + + // Search submit/clear write `q` to the URL. Cannot reuse `router.pathname` + // here (`/squads/[handle]`) the way RouterPostsSearch does for static + // routes — Next's router.replace throws/produces a literal + // "/squads/[handle]" href when the dynamic segment isn't present in the + // provided query object, so we build the concrete path from `asPath`. + const onSubmitSquadSearch = useCallback( + (query: string) => { + logEvent({ + event_name: LogEvent.SubmitSearch, + extra: JSON.stringify({ + query, + provider: SearchProviderEnum.Posts, + squad: squadId, + }), + }); + + const basePath = router.asPath.split('?')[0]; + const searchParams = new URLSearchParams(window.location.search); + if (query) { + searchParams.set('q', query); + } else { + searchParams.delete('q'); + } + + return router.replace( + getPathnameWithQuery(basePath, searchParams), + undefined, + { shallow: true }, + ); + }, + [logEvent, router, squadId], + ); + + const onClearSquadSearch = useCallback(() => { + const basePath = router.asPath.split('?')[0]; + const searchParams = new URLSearchParams(window.location.search); + searchParams.delete('q'); + + return router.replace( + getPathnameWithQuery(basePath, searchParams), + undefined, + { shallow: true }, + ); + }, [router]); + + const onFocusSquadSearch = useCallback(() => { + logEvent({ event_name: LogEvent.FocusSearch }); + }, [logEvent]); + useEffect(() => { if (!isForbidden) { return; @@ -464,18 +585,25 @@ const SquadPage = ({ } options={{ refetchOnMount: true }} - header={} + header={ + + } + /> + } inlineHeader allowPin />