diff --git a/packages/shared/package.json b/packages/shared/package.json index 472497b6f23..0972a29239c 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -126,6 +126,7 @@ "@tiptap/react": "^3.22.5", "@tiptap/starter-kit": "^3.22.5", "check-password-strength": "^2.0.10", + "cmdk": "^1.0.0", "emojibase-data": "^17.0.0", "fetch-event-stream": "^0.1.6", "graphql-ws": "^5.5.5", diff --git a/packages/shared/src/components/MainLayout.tsx b/packages/shared/src/components/MainLayout.tsx index 7b5f13c8a6a..ef63d4e726a 100644 --- a/packages/shared/src/components/MainLayout.tsx +++ b/packages/shared/src/components/MainLayout.tsx @@ -32,6 +32,8 @@ import { AuthTriggers } from '../lib/auth'; import PlusMobileEntryBanner from './marketing/banners/PlusMobileEntryBanner'; import usePlusEntry from '../hooks/usePlusEntry'; import { SearchProvider } from '../contexts/search/SearchContext'; +import { SpotlightProvider } from './spotlight/SpotlightContext'; +import { SpotlightHost } from './spotlight/SpotlightHost'; import { FeedbackWidget } from './feedback'; import { isExtension } from '../lib/func'; @@ -185,6 +187,7 @@ function MainLayoutComponent({ + {plusEntryAnnouncementBar && ( ( - + + + ); diff --git a/packages/shared/src/components/layout/MainLayoutHeader.tsx b/packages/shared/src/components/layout/MainLayoutHeader.tsx index bc5e1fb60c2..ce6a6ce14a2 100644 --- a/packages/shared/src/components/layout/MainLayoutHeader.tsx +++ b/packages/shared/src/components/layout/MainLayoutHeader.tsx @@ -24,10 +24,10 @@ export interface MainLayoutHeaderProps { onLogoClick?: (e: React.MouseEvent) => unknown; } -const SearchPanel = dynamic( +const SpotlightTrigger = dynamic( () => import( - /* webpackChunkName: "searchPanel" */ '../search/SearchPanel/SearchPanel' + /* webpackChunkName: "spotlightTrigger" */ '../spotlight/SpotlightTrigger' ), ); @@ -76,18 +76,18 @@ function MainLayoutHeader({ const renderSearchPanel = useCallback( () => shouldUseLoadedSettings && ( - +
+ +
), [shouldUseLoadedSettings, isSearchPage, hasBanner], ); diff --git a/packages/shared/src/components/search/SearchPanel/SearchPanel.tsx b/packages/shared/src/components/search/SearchPanel/SearchPanel.tsx deleted file mode 100644 index 8255049038b..00000000000 --- a/packages/shared/src/components/search/SearchPanel/SearchPanel.tsx +++ /dev/null @@ -1,131 +0,0 @@ -import type { ReactElement } from 'react'; -import React, { useEffect, useMemo, useRef, useState } from 'react'; -import classNames from 'classnames'; -import { useRouter } from 'next/router'; -import dynamic from 'next/dynamic'; -import { SearchPanelInput } from './SearchPanelInput'; -import { minSearchQueryLength } from '../../../graphql/search'; -import type { SearchPanelContextValue } from './SearchPanelContext'; -import { SearchPanelContext } from './SearchPanelContext'; -import { defaultSearchProvider, providerToLabelTextMap } from './common'; - -const SearchPanelDropdown = dynamic( - () => - import( - /* webpackChunkName: "searchPanelDropdown" */ './SearchPanelDropdown' - ), - { ssr: false }, -); - -export type SearchPanelProps = { - className?: SearchPanelClassName; -}; - -export type SearchPanelClassName = { - container?: string; - field?: string; -}; - -export const SearchPanel = ({ className }: SearchPanelProps): ReactElement => { - const { query } = useRouter(); - - const [state, setState] = useState(() => { - return { - provider: undefined, - query: '', - isActive: false, - providerText: undefined, - providerIcon: undefined, - }; - }); - - const searchPanelRef = useRef(); - - useEffect(() => { - const searchQuery = query?.q?.toString(); - - if (!searchQuery) { - return; - } - - setState((currentState) => { - if (!currentState.query) { - return { ...currentState, query: searchQuery }; - } - - return currentState; - }); - }, [query?.q]); - - const searchPanel = useMemo(() => { - return { - ...state, - setProvider: ({ provider, text, icon }) => { - setState((currentState) => { - return { - ...currentState, - provider, - providerText: text || undefined, - providerIcon: icon || undefined, - }; - }); - }, - setActive: ({ isActive }) => { - setState((currentState) => { - return { - ...currentState, - isActive, - }; - }); - }, - }; - }, [state]); - - const showDropdown = - state.isActive && state.query.length >= minSearchQueryLength; - - return ( - -
- { - setState((currentState) => { - return { - ...currentState, - query: newValue, - // reset provider label while typing - provider: undefined, - providerText: providerToLabelTextMap[defaultSearchProvider], - providerIcon: undefined, - }; - }); - }} - inputProps={{ - value: state.query, - onFocus: () => { - searchPanel.setActive({ - isActive: true, - }); - }, - }} - > - {showDropdown && ( - - )} - -
-
- ); -}; - -export default SearchPanel; diff --git a/packages/shared/src/components/search/SearchPanel/SearchPanelAction.tsx b/packages/shared/src/components/search/SearchPanel/SearchPanelAction.tsx deleted file mode 100644 index c5afefcf009..00000000000 --- a/packages/shared/src/components/search/SearchPanel/SearchPanelAction.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import type { ReactElement } from 'react'; -import React, { useContext } from 'react'; -import classNames from 'classnames'; -import type { SearchProviderEnum } from '../../../graphql/search'; -import { SearchPanelContext } from './SearchPanelContext'; -import { SearchPanelItem } from './SearchPanelItem'; -import { useSearchProvider } from '../../../hooks/search'; -import { useSearchPanelAction } from './useSearchPanelAction'; -import { - defaultSearchProvider, - providerToIconMap, - providerToLabelTextMap, -} from './common'; -import { IconSize } from '../../Icon'; -import { useLogContext } from '../../../contexts/LogContext'; -import { LogEvent } from '../../../lib/log'; -import { useSearchContextProvider } from '../../../contexts/search/SearchContext'; - -export type SearchPanelActionProps = { - provider: SearchProviderEnum; -}; - -export const SearchPanelAction = ({ - provider, -}: SearchPanelActionProps): ReactElement => { - const searchPanel = useContext(SearchPanelContext); - const Icon = providerToIconMap[provider]; - const { search } = useSearchProvider(); - const { time, contentCurationFilter } = useSearchContextProvider(); - const itemProps = useSearchPanelAction({ provider }); - const isDefaultProvider = provider === defaultSearchProvider; - const isDefaultActive = !searchPanel.provider && isDefaultProvider; - const { logEvent } = useLogContext(); - - return ( - } - onClick={() => { - logEvent({ - event_name: LogEvent.SubmitSearch, - extra: JSON.stringify({ - query: searchPanel.query, - provider, - filters: { time, contentCuration: contentCurationFilter }, - }), - }); - - search({ provider, query: searchPanel.query }); - }} - className={classNames(isDefaultActive && 'bg-surface-float')} - data-search-panel-item={!isDefaultProvider} - tabIndex={isDefaultProvider ? -1 : undefined} - {...itemProps} - > - - {searchPanel.query}{' '} - - {providerToLabelTextMap[provider]} - - - - ); -}; diff --git a/packages/shared/src/components/search/SearchPanel/SearchPanelCustomAction.tsx b/packages/shared/src/components/search/SearchPanel/SearchPanelCustomAction.tsx deleted file mode 100644 index c074bf0e7d8..00000000000 --- a/packages/shared/src/components/search/SearchPanel/SearchPanelCustomAction.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import type { ReactElement, ReactNode } from 'react'; -import React from 'react'; -import type { SearchPanelItemProps } from './SearchPanelItem'; -import { SearchPanelItem } from './SearchPanelItem'; -import { useSearchPanelAction } from './useSearchPanelAction'; -import type { SearchProviderEnum } from '../../../graphql/search'; - -export type SearchPanelCustomActionProps = { - provider: SearchProviderEnum; - children: ReactNode; -} & Omit< - SearchPanelItemProps, - 'onMouseEnter' | 'onMouseLeave' | 'onFocus' | 'onBlur' ->; - -export const SearchPanelCustomAction = ({ - provider, - children, - ...rest -}: SearchPanelCustomActionProps): ReactElement => { - const itemProps = useSearchPanelAction({ provider }); - - return ( - - {children} - - ); -}; diff --git a/packages/shared/src/components/search/SearchPanel/SearchPanelDropdown.tsx b/packages/shared/src/components/search/SearchPanel/SearchPanelDropdown.tsx deleted file mode 100644 index e834f84522a..00000000000 --- a/packages/shared/src/components/search/SearchPanel/SearchPanelDropdown.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import type { MutableRefObject, ReactElement } from 'react'; -import React from 'react'; -import { SearchProviderEnum } from '../../../graphql/search'; -import { ArrowKeyEnum, isExtension } from '../../../lib/func'; -import { LogEvent } from '../../../lib/log'; -import { ArrowIcon } from '../../icons'; -import { SearchPanelAction } from './SearchPanelAction'; -import { SearchPanelCustomAction } from './SearchPanelCustomAction'; -import { SearchPanelPostSuggestions } from './SearchPanelPostSuggestions'; -import { SearchPanelSourceSuggestions } from './SearchPanelSourceSuggestions'; -import { SearchPanelTagSuggestions } from './SearchPanelTagSuggestions'; -import { SearchPanelUserSuggestions } from './SearchPanelUserSuggestions'; -import { useEventListener } from '../../../hooks'; -import { useSearchProvider } from '../../../hooks/search'; -import { useLogContext } from '../../../contexts/LogContext'; -import { useSearchContextProvider } from '../../../contexts/search/SearchContext'; - -type Props = { - anchor: MutableRefObject; - query?: string; -}; - -const SearchPanelDropdown = ({ query = '', anchor }: Props): ReactElement => { - const { search } = useSearchProvider(); - const { time, contentCurationFilter } = useSearchContextProvider(); - const { logEvent } = useLogContext(); - - useEventListener(anchor, 'keydown', (event) => { - const navigableElements = [ - ...anchor.current.querySelectorAll( - '[data-search-panel-item="true"]', - ), - ]; - let activeElementIndex = navigableElements.findIndex( - (element) => element.getAttribute('data-search-panel-active') === 'true', - ); - - if (activeElementIndex === -1) { - activeElementIndex = 0; - } - - const keyToIndexModifier: Partial> = { - [ArrowKeyEnum.Up]: -1, - [ArrowKeyEnum.Down]: 1, - }; - - if (activeElementIndex !== 0) { - keyToIndexModifier[ArrowKeyEnum.Left] = -1; - keyToIndexModifier[ArrowKeyEnum.Right] = 1; - } - - const supportedKeys = Object.keys(keyToIndexModifier); - - const pressedKey = supportedKeys.find((key) => key === event.key); - - if (!pressedKey) { - return; - } - - event.preventDefault(); - - const indexModifier = keyToIndexModifier[pressedKey as ArrowKeyEnum]; - - if (indexModifier === undefined) { - return; - } - - const nextElement = navigableElements[activeElementIndex + indexModifier]; - - if (nextElement) { - nextElement.focus(); - } - }); - - return ( -
-
- - {isExtension && ( - - )} - - - - - { - logEvent({ - event_name: LogEvent.SubmitSearch, - extra: JSON.stringify({ - query, - provider: SearchProviderEnum.Posts, - filters: { time, contentCuration: contentCurationFilter }, - }), - }); - - search({ - provider: SearchProviderEnum.Posts, - query, - }); - }} - > -
- See more posts -
-
-
-
- ); -}; - -export default SearchPanelDropdown; diff --git a/packages/shared/src/components/search/SearchPanel/SearchPanelInput.tsx b/packages/shared/src/components/search/SearchPanel/SearchPanelInput.tsx deleted file mode 100644 index 54311f115d8..00000000000 --- a/packages/shared/src/components/search/SearchPanel/SearchPanelInput.tsx +++ /dev/null @@ -1,282 +0,0 @@ -import type { - FormEvent, - InputHTMLAttributes, - MouseEvent, - ReactElement, - ReactNode, -} from 'react'; -import React, { useContext, useRef } from 'react'; -import classNames from 'classnames'; -import { useRouter } from 'next/router'; -import { BaseField, FieldInput } from '../../fields/common'; -import { LogEvent, TargetId } from '../../../lib/log'; -import { IconSize } from '../../Icon'; -import { getFieldFontColor } from '../../fields/BaseFieldContainer'; -import { AiIcon, ClearIcon } from '../../icons'; -import { useInputField } from '../../../hooks/useInputField'; -import { useLogContext } from '../../../contexts/LogContext'; -import { useAuthContext } from '../../../contexts/AuthContext'; -import { AuthTriggers } from '../../../lib/auth'; -import { SearchPanelContext } from './SearchPanelContext'; -import { ViewSize, useEventListener, useViewSize } from '../../../hooks'; -import { - isAppleDevice, - isNullOrUndefined, - isSpecialKeyPressed, -} from '../../../lib/func'; -import { KeyboadShortcutLabel } from '../../KeyboardShortcutLabel'; -import { SearchPanelProvider } from './SearchPanelProvider'; -import { minSearchQueryLength } from '../../../graphql/search'; -import { SearchPanelInputCursor } from './SearchPanelInputCursor'; -import { useSearchProvider } from '../../../hooks/search'; -import { defaultSearchProvider, providerToLabelTextMap } from './common'; -import { Button, ButtonSize } from '../../buttons/Button'; -import { useSearchPanelAction } from './useSearchPanelAction'; -import { webappUrl } from '../../../lib/constants'; -import { useSearchContextProvider } from '../../../contexts/search/SearchContext'; - -export type SearchPanelInputClassName = { - container?: string; - field?: string; - form?: string; -}; - -export type SearchPanelInputProps = { - className?: SearchPanelInputClassName; - valueChanged?: (value: string) => void; - inputProps?: InputHTMLAttributes; - children?: ReactNode; -}; - -const shortcutKeys = [isAppleDevice() ? '⌘' : 'Ctrl', 'K']; - -export const SearchPanelInput = ({ - className, - inputProps, - valueChanged, - children, -}: SearchPanelInputProps): ReactElement => { - const router = useRouter(); - const { search } = useSearchProvider(); - const { time, contentCurationFilter } = useSearchContextProvider(); - const searchPanel = useContext(SearchPanelContext); - const fieldRef = useRef(); - const { logEvent } = useLogContext(); - const { - value, - readOnly, - disabled, - onFocus: externalOnFocus, - onBlur: externalOnBlur, - onClick: externalOnClick, - placeholder = searchPanel.isActive - ? 'Search posts or ask a question...' - : 'Search', - } = inputProps || {}; - const { inputRef, focused, hasInput, onFocus, onBlur, onInput, setInput } = - useInputField(value, valueChanged); - const { isLoggedIn, showLogin } = useAuthContext(); - const isLaptop = useViewSize(ViewSize.Laptop); - - const onInputClick = () => { - if (!isLoggedIn) { - showLogin({ trigger: AuthTriggers.SearchInput }); - } - }; - - const onSubmit = (event: FormEvent, input?: string): void => { - event.preventDefault(); - - if (!isLoggedIn) { - return showLogin({ trigger: AuthTriggers.SearchInput }); - } - - const finalValue = input ?? inputRef.current.value; - const provider = searchPanel.provider ?? defaultSearchProvider; - - logEvent({ - event_name: LogEvent.SubmitSearch, - extra: JSON.stringify({ - query: finalValue, - provider, - filters: { time, contentCuration: contentCurationFilter }, - }), - }); - - setInput(finalValue); - - searchPanel.setActive({ - isActive: false, - }); - - inputRef.current?.blur(); - - return search({ provider, query: finalValue }); - }; - - useEventListener(globalThis, 'click', (event) => { - if ( - !isNullOrUndefined(fieldRef.current) && - !fieldRef.current.contains(event.target as Node) && - fieldRef.current - ) { - onBlur(); - - searchPanel.setActive({ - isActive: false, - }); - } - }); - - const showDropdown = - searchPanel.isActive && searchPanel.query.length >= minSearchQueryLength; - - useEventListener(globalThis, 'keydown', (event) => { - if (isSpecialKeyPressed({ event }) && event.key === 'k') { - event.preventDefault(); - - if (searchPanel.isActive) { - inputRef.current?.blur(); - searchPanel.setActive({ - isActive: false, - }); - } else { - inputRef.current?.focus(); - searchPanel.setActive({ - isActive: true, - }); - - logEvent({ - event_name: LogEvent.KeyboardShortcutTriggered, - target_id: TargetId.SearchActivation, - }); - } - } - }); - - useEventListener(globalThis, 'keydown', (event) => { - if (event.key === 'Escape') { - event.preventDefault(); - - if (searchPanel.isActive) { - inputRef.current?.blur(); - searchPanel.setActive({ - isActive: false, - }); - } - } - }); - - const itemProps = useSearchPanelAction({ - provider: undefined, - text: providerToLabelTextMap[defaultSearchProvider], - }); - - return ( -
-
- - - { - onFocus(); - externalOnFocus?.(event); - itemProps.onFocus(event); - - logEvent({ event_name: LogEvent.FocusSearch }); - }} - onBlur={(event) => { - onBlur(); - externalOnBlur?.(event); - itemProps.onBlur(event); - }} - onClick={(event) => { - onInputClick(); - externalOnClick?.(event); - }} - onInput={onInput} - type="primary" - autoComplete="off" - className={classNames( - 'h-full flex-1', - searchPanel.isActive - ? '!placeholder-text-quaternary' - : '!placeholder-text-tertiary', - getFieldFontColor({ readOnly, disabled, hasInput, focused }), - )} - /> -
- {searchPanel.isActive && !!searchPanel.query && ( - -
- )} -
-
- {!searchPanel.isActive && ( - - )} - {searchPanel.isActive && - searchPanel.query.length >= minSearchQueryLength && ( - <> - {isLaptop && } - - - )} -
- - {children} - - - ); -}; diff --git a/packages/shared/src/components/search/SearchPanel/SearchPanelInputCursor.tsx b/packages/shared/src/components/search/SearchPanel/SearchPanelInputCursor.tsx deleted file mode 100644 index e763f52909d..00000000000 --- a/packages/shared/src/components/search/SearchPanel/SearchPanelInputCursor.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import type { ReactElement } from 'react'; -import React, { useContext } from 'react'; -import classNames from 'classnames'; -import { SearchPanelContext } from './SearchPanelContext'; -import { useDomPurify } from '../../../hooks/useDomPurify'; - -export type SearchPanelInputCursorProps = { - className?: Partial<{ - main: string; - input: string; - }>; -}; - -export const SearchPanelInputCursor = ({ - className, -}: SearchPanelInputCursorProps): ReactElement | null => { - const searchPanel = useContext(SearchPanelContext); - const purify = useDomPurify(); - - if (!searchPanel.providerText) { - return null; - } - - const purifySanitize = purify?.sanitize; - - return ( -
- - {!!purifySanitize && ( -
- -
- )} -
- ); -}; diff --git a/packages/shared/src/components/search/SearchPanel/SearchPanelPostSuggestions.tsx b/packages/shared/src/components/search/SearchPanel/SearchPanelPostSuggestions.tsx deleted file mode 100644 index c66801e77f7..00000000000 --- a/packages/shared/src/components/search/SearchPanel/SearchPanelPostSuggestions.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import classNames from 'classnames'; -import type { ReactElement } from 'react'; -import React, { useContext } from 'react'; -import { useRouter } from 'next/router'; -import { SearchIcon } from '../../icons'; -import type { SearchPanelItemProps } from './SearchPanelItem'; -import { SearchPanelItem } from './SearchPanelItem'; -import type { SearchSuggestion } from '../../../graphql/search'; -import { - SearchProviderEnum, - sanitizeSearchTitleMatch, -} from '../../../graphql/search'; -import { - useSearchProvider, - useSearchProviderSuggestions, -} from '../../../hooks/search'; -import { SearchPanelContext } from './SearchPanelContext'; -import { useDomPurify } from '../../../hooks/useDomPurify'; -import { useSearchPanelAction } from './useSearchPanelAction'; -import { webappUrl } from '../../../lib/constants'; -import { LogEvent, Origin, TargetType } from '../../../lib/log'; -import { useLogContext } from '../../../contexts/LogContext'; - -export type SearchPanelPostSuggestionsProps = { - className?: string; - title: string; -}; - -const PanelItem = ({ - suggestion, - ...rest -}: Omit & { suggestion: SearchSuggestion }) => { - const itemProps = useSearchPanelAction({ - provider: SearchProviderEnum.Posts, - text: suggestion.title, - }); - const purify = useDomPurify(); - const purifySanitize = purify?.sanitize; - - return ( - } {...itemProps}> - {!!purifySanitize && ( - - )} - - ); -}; - -export const SearchPanelPostSuggestions = ({ - className, - title, -}: SearchPanelPostSuggestionsProps): ReactElement | null => { - const router = useRouter(); - const { logEvent } = useLogContext(); - const searchPanel = useContext(SearchPanelContext); - const { search } = useSearchProvider(); - - const { suggestions } = useSearchProviderSuggestions({ - provider: SearchProviderEnum.Posts, - query: searchPanel.query, - }); - - const onSuggestionClick = (suggestion: SearchSuggestion) => { - const searchQuery = suggestion.title.replace(sanitizeSearchTitleMatch, ''); - - if (suggestion.id) { - logEvent({ - event_name: LogEvent.Click, - target_type: TargetType.SearchRecommendation, - target_id: suggestion.id, - feed_item_title: searchQuery, - extra: JSON.stringify({ - origin: Origin.HomePage, - provider: SearchProviderEnum.Posts, - }), - }); - router.push(`${webappUrl}posts/${suggestion.id}`); - - return; - } - - search({ provider: SearchProviderEnum.Posts, query: searchQuery }); - }; - - if (!suggestions?.hits?.length) { - return null; - } - - return ( -
-
-
- - {title} - -
-
- {suggestions?.hits?.map((suggestion) => { - return ( - { - onSuggestionClick(suggestion); - }} - /> - ); - })} -
- ); -}; diff --git a/packages/shared/src/components/search/SearchPanel/SearchPanelProvider.tsx b/packages/shared/src/components/search/SearchPanel/SearchPanelProvider.tsx deleted file mode 100644 index bc4770a3209..00000000000 --- a/packages/shared/src/components/search/SearchPanel/SearchPanelProvider.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import type { FunctionComponent, ReactElement } from 'react'; -import React, { useContext } from 'react'; -import classNames from 'classnames'; -import { SearchProviderEnum } from '../../../graphql/search'; -import type { IconProps } from '../../Icon'; -import { IconSize } from '../../Icon'; -import { SearchPanelContext } from './SearchPanelContext'; -import { providerToIconMap } from './common'; - -export type SearchPanelProviderProps = { - className?: string; -}; - -const providerToComponentMap: Record< - SearchProviderEnum, - FunctionComponent | undefined -> = { - ...providerToIconMap, - [SearchProviderEnum.Posts]: undefined, -}; - -export const SearchPanelProvider = ({ - className, -}: SearchPanelProviderProps): ReactElement | null => { - const searchPanel = useContext(SearchPanelContext); - - if (searchPanel.providerIcon) { - return searchPanel.providerIcon; - } - - if (!searchPanel.provider) { - return null; - } - - const Component = providerToComponentMap[searchPanel.provider]; - - if (!Component) { - return null; - } - - return ( - - ); -}; diff --git a/packages/shared/src/components/search/SearchPanel/SearchPanelTagSuggestions.tsx b/packages/shared/src/components/search/SearchPanel/SearchPanelTagSuggestions.tsx deleted file mode 100644 index 0aa51bb925d..00000000000 --- a/packages/shared/src/components/search/SearchPanel/SearchPanelTagSuggestions.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import classNames from 'classnames'; -import type { ReactElement } from 'react'; -import React, { useContext } from 'react'; -import { useRouter } from 'next/router'; -import type { SearchSuggestion } from '../../../graphql/search'; -import { SearchProviderEnum } from '../../../graphql/search'; -import { useSearchProviderSuggestions } from '../../../hooks/search'; -import { SearchPanelContext } from './SearchPanelContext'; -import { useSearchPanelAction } from './useSearchPanelAction'; -import { LogEvent, Origin, TargetType } from '../../../lib/log'; -import { useLogContext } from '../../../contexts/LogContext'; -import { SearchPanelItemContainer } from './SearchPanelInputContainer'; -import { TagLink } from '../../TagLinks'; -import { webappUrl } from '../../../lib/constants'; -import type { ButtonProps } from '../../buttons/Button'; - -export type SearchPanelTagSuggestionsProps = { - className?: string; - title: string; -}; - -type PanelItemProps = Pick, 'onClick'> & { - suggestion: SearchSuggestion; -}; - -const PanelItem = ({ suggestion, onClick }: PanelItemProps) => { - const itemProps = useSearchPanelAction({ - provider: SearchProviderEnum.Tags, - text: suggestion.title, - }); - - return ( - - { - event.preventDefault(); - }, - }} - /> - - ); -}; - -export const SearchPanelTagSuggestions = ({ - className, - title, -}: SearchPanelTagSuggestionsProps): ReactElement => { - const router = useRouter(); - const { logEvent } = useLogContext(); - const searchPanel = useContext(SearchPanelContext); - - const { suggestions } = useSearchProviderSuggestions({ - provider: SearchProviderEnum.Tags, - query: searchPanel.query, - limit: 5, - }); - - const onSuggestionClick = (suggestion: SearchSuggestion) => { - const tag = suggestion.id || suggestion.title.toLowerCase(); - - logEvent({ - event_name: LogEvent.Click, - target_type: TargetType.SearchRecommendation, - target_id: tag, - feed_item_title: tag, - extra: JSON.stringify({ - origin: Origin.HomePage, - provider: SearchProviderEnum.Tags, - }), - }); - - router.push(`${webappUrl}tags/${tag}`); - }; - - if (!suggestions?.hits?.length) { - return null; - } - - return ( -
-
-
- - {title} - -
-
-
- {suggestions?.hits?.map((suggestion) => { - return ( - { - event.preventDefault(); - - onSuggestionClick(suggestion); - }} - /> - ); - })} -
-
- ); -}; diff --git a/packages/shared/src/components/search/SearchPanel/index.ts b/packages/shared/src/components/search/SearchPanel/index.ts index f6e12125fd6..d0b93236650 100644 --- a/packages/shared/src/components/search/SearchPanel/index.ts +++ b/packages/shared/src/components/search/SearchPanel/index.ts @@ -1,2 +1 @@ -export * from './SearchPanel'; export * from './common'; diff --git a/packages/shared/src/components/spotlight/ScopeBreadcrumbs.tsx b/packages/shared/src/components/spotlight/ScopeBreadcrumbs.tsx new file mode 100644 index 00000000000..22b82988c6f --- /dev/null +++ b/packages/shared/src/components/spotlight/ScopeBreadcrumbs.tsx @@ -0,0 +1,88 @@ +import type { ComponentType, ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { + DocsIcon, + HashtagIcon, + SquadIcon, + TerminalIcon, + UserIcon, +} from '../icons'; +import type { IconProps } from '../Icon'; +import { IconSize } from '../Icon'; +import { isAppleDevice } from '../../lib/func'; +import { scopeMeta, scopeOrder, SpotlightScope } from './types'; + +interface ScopeBreadcrumbsProps { + scope: SpotlightScope; + onSelect: (scope: SpotlightScope) => void; +} + +const altLabel = isAppleDevice() ? '⌥' : 'Alt'; + +export const scopeIcons: Record< + Exclude, + ComponentType +> = { + [SpotlightScope.Actions]: TerminalIcon, + [SpotlightScope.Posts]: DocsIcon, + [SpotlightScope.Squads]: SquadIcon, + [SpotlightScope.People]: UserIcon, + [SpotlightScope.Tags]: HashtagIcon, +}; + +/** + * Filter chips shown directly under the search input. + * + * When `scope === All` we render every available filter as a calm, + * uniform tab bar — no "available vs dimmed" two-tone (it was confusing + * which were clickable). Picking one is a one-way move: the chip + * collapses into a pill inside the input field (Slack / Apple Spotlight + * Tahoe pattern) and this bar disappears, so we return null to keep the + * surface uncluttered. + * + * To leave the active scope the user clicks the in-input pill or hits + * Backspace on an empty input — both are wired up in `Spotlight.tsx`. + */ +export const ScopeBreadcrumbs = ({ + scope, + onSelect, +}: ScopeBreadcrumbsProps): ReactElement | null => { + if (scope !== SpotlightScope.All) { + return null; + } + + return ( +
+ {scopeOrder.map((s) => { + const meta = scopeMeta[s]; + const Icon = scopeIcons[s]; + return ( + + ); + })} +
+ ); +}; + +export default ScopeBreadcrumbs; diff --git a/packages/shared/src/components/spotlight/ScopeFilterPill.tsx b/packages/shared/src/components/spotlight/ScopeFilterPill.tsx new file mode 100644 index 00000000000..f74cc1be29c --- /dev/null +++ b/packages/shared/src/components/spotlight/ScopeFilterPill.tsx @@ -0,0 +1,55 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { IconSize } from '../Icon'; +import { isAppleDevice } from '../../lib/func'; +import type { SpotlightScope } from './types'; +import { scopeMeta } from './types'; +import { scopeIcons } from './ScopeBreadcrumbs'; + +interface ScopeFilterPillProps { + scope: Exclude; + onRemove: () => void; +} + +const backspaceLabel = isAppleDevice() ? '⌫' : 'Backspace'; + +/** + * Slack/Apple-Tahoe-style search token shown inside the input field at + * the start of the query. Replaces the old "active scope chip with X + * button" treatment — clicking the pill OR pressing Backspace on an + * empty input removes it (the latter is wired up by the input's own + * `onKeyDown`, this component just exposes the click affordance). + * + * The pill is intentionally calm (subtle background, no accent color) + * so it reads as "context for the input" rather than as a destructive + * action. + */ +export const ScopeFilterPill = ({ + scope, + onRemove, +}: ScopeFilterPillProps): ReactElement => { + const meta = scopeMeta[scope]; + const Icon = scopeIcons[scope]; + + return ( + + ); +}; + +export default ScopeFilterPill; diff --git a/packages/shared/src/components/spotlight/Spotlight.tsx b/packages/shared/src/components/spotlight/Spotlight.tsx new file mode 100644 index 00000000000..92af4228950 --- /dev/null +++ b/packages/shared/src/components/spotlight/Spotlight.tsx @@ -0,0 +1,1146 @@ +import type { ReactElement } from 'react'; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import classNames from 'classnames'; +import { useRouter } from 'next/router'; +import { Command } from 'cmdk'; +import ReactModal from 'react-modal'; +import { ClearIcon, ClickIcon, SearchIcon } from '../icons'; +import { IconSize } from '../Icon'; +import { Loader } from '../Loader'; +import { ElementPlaceholder } from '../ElementPlaceholder'; +import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; +import { Drawer, DrawerPosition } from '../drawers/Drawer'; +import { ViewSize, useViewSize } from '../../hooks'; +import { useAuthContext } from '../../contexts/AuthContext'; +import { AuthTriggers } from '../../lib/auth'; +import { isExtension, isSpecialKeyPressed } from '../../lib/func'; +import { fallbackImages } from '../../lib/config'; +import { + groupLabels, + groupOrder, + scopeMeta, + scopeOrder, + type SpotlightCommand, + SpotlightGroup, + SpotlightScope, +} from './types'; +import { useSpotlight } from './SpotlightContext'; +import { useRecentCommands } from './useRecentCommands'; +import { useSpotlightCommands } from './useSpotlightCommands'; +import { useSpotlightSearchCommands } from './commands/search'; +import { ScopeBreadcrumbs } from './ScopeBreadcrumbs'; +import { ScopeFilterPill } from './ScopeFilterPill'; +import { useQuickKeyDispatch } from './useQuickKeyDispatch'; +import { + spotlightCommandFilter, + SPOTLIGHT_PASSTHROUGH_KEYWORD, +} from './spotlightFilter'; + +const groupHeadingClass = + '[&_[cmdk-group-heading]]:sticky [&_[cmdk-group-heading]]:top-0 [&_[cmdk-group-heading]]:z-1 [&_[cmdk-group-heading]]:bg-background-default [&_[cmdk-group-heading]]:pb-1.5 [&_[cmdk-group-heading]]:pl-4 [&_[cmdk-group-heading]]:pr-3 [&_[cmdk-group-heading]]:pt-3 [&_[cmdk-group-heading]]:text-[0.6875rem] [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:tracking-normal [&_[cmdk-group-heading]]:text-text-quaternary'; + +const firstHeadingNoTopPaddingClass = + '[&_[cmdk-group]:first-child_[cmdk-group-heading]]:pt-1'; + +const SUGGESTED_COMMAND_IDS = [ + 'create.compose-text', + 'settings.theme', + 'nav.bookmarks', + 'create.feedback', + 'help.shortcuts', + 'nav.plus', +]; + +interface RowProps { + command: SpotlightCommand; + isLoggedIn: boolean; + isPlus: boolean; + isMobile?: boolean; + pendingConfirmId: string | null; + onSelect: (command: SpotlightCommand) => void; +} + +const rowBaseClass = + 'group/spotlight-row mx-2 flex min-w-0 cursor-pointer items-center gap-3 overflow-hidden rounded-10 px-3 text-left aria-disabled:cursor-not-allowed aria-disabled:opacity-40 data-[selected=true]:bg-surface-hover'; + +const TypedAvatar = ({ + src, + alt, + rounded, + className, +}: { + src?: string; + alt: string; + rounded: 'full' | '8'; + className?: string; +}): ReactElement => ( + + {alt} { + const target = e.target as HTMLImageElement; + if (target.src !== fallbackImages.avatar) { + target.src = fallbackImages.avatar; + } + }} + /> + +); + +const TagGlyph = (): ReactElement => ( + + # + +); + +const SeeAllGlyph = (): ReactElement => ( + + + +); + +const TitleSubtitle = ({ + title, + subtitle, + showSubtitleAlways = false, +}: { + title: string; + subtitle?: string; + showSubtitleAlways?: boolean; +}): ReactElement => ( + + + {title} + + {subtitle && ( + + {subtitle} + + )} + +); + +type RowParts = { leading: ReactElement; body: ReactElement }; + +const buildRowParts = ( + command: SpotlightCommand, + Icon: SpotlightCommand['icon'], +): RowParts => { + const { meta } = command; + switch (meta?.kind) { + case 'post': + return { + leading: ( + + ), + body: ( + + ), + }; + case 'source': + case 'user': + return { + leading: ( + + ), + body: ( + + ), + }; + case 'tag': + return { + leading: , + body: ( + + #{meta.tagName} + + ), + }; + case 'see-all': + return { + leading: , + body: ( + + {command.title} + + ), + }; + default: + return { + leading: ( + + + + ), + body: ( + + ), + }; + } +}; + +const SpotlightRow = ({ + command, + isLoggedIn, + isPlus, + isMobile, + pendingConfirmId, + onSelect, +}: RowProps): ReactElement => { + const Icon = command.icon; + const isPending = pendingConfirmId === command.id; + const isPlusGated = command.plusBadge && !isPlus; + const isAuthGated = command.requiresAuth && !isLoggedIn; + const { meta } = command; + const value = `${command.title} ${command.subtitle ?? ''} ${( + command.keywords ?? [] + ).join(' ')}`.toLowerCase(); + // Backend-driven entity rows (posts/sources/users/tags) and the + // "see all in " CTA opt out of cmdk's local filter via this + // marker keyword. The API already ranked them — re-filtering locally + // would drop perfectly relevant results when the title doesn't share + // characters with the query (e.g. tag aliases, handle vs display name). + const cmdKeywords = meta ? [SPOTLIGHT_PASSTHROUGH_KEYWORD] : undefined; + + const { leading, body } = buildRowParts(command, Icon); + + return ( + onSelect(command)} + aria-keyshortcuts={command.shortcut} + className={classNames( + rowBaseClass, + isMobile ? 'h-12' : 'h-10', + command.destructive && 'data-[selected=true]:text-status-error', + )} + > + {leading} + {body} + {isAuthGated && ( + + Sign in + + )} + {isPlusGated && ( + + Plus + + )} + {isPending && } + {!isPending && command.shortcut && ( + + {command.shortcut} + + )} + + ); +}; + +interface RowsProps { + commands: SpotlightCommand[]; + isLoggedIn: boolean; + isPlus: boolean; + isMobile?: boolean; + pendingConfirmId: string | null; + onSelect: (command: SpotlightCommand) => void; +} + +const renderRows = ({ + commands, + isLoggedIn, + isPlus, + isMobile, + pendingConfirmId, + onSelect, +}: RowsProps) => + commands.map((command) => ( + + )); + +const SkeletonRows = ({ count = 3 }: { count?: number }) => ( +
+ {Array.from({ length: count }).map((_, i) => ( +
+ + +
+ ))} +
+); + +interface ConfirmRowProps { + command: SpotlightCommand; + onCancel: () => void; + onConfirm: () => void; +} + +const DestructiveConfirm = ({ + command, + onCancel, + onConfirm, +}: ConfirmRowProps): ReactElement => ( +
+

+ {command.id === 'actions.logout' + ? 'Log out of daily.dev?' + : `Confirm: ${command.title}`} +

+

+ Press Enter again to confirm, or Esc to cancel. +

+
+ + +
+
+); + +interface SpotlightDialogProps { + isOpen: boolean; + onClose: () => void; + /** Optional analytics callback. Injected by Phase 1's wiring. */ + onCommandRun?: (command: SpotlightCommand) => void; + /** Fires when the user opens via Cmd+K. */ + onOpenViaShortcut?: () => void; +} + +const Hint = ({ label, combo }: { label: string; combo: string }) => ( + + + {combo} + + {label} + +); + +const isInExtensionIframe = (target: EventTarget | null): boolean => { + if (!isExtension || typeof window === 'undefined') { + return false; + } + const node = target instanceof HTMLElement ? target : null; + if (!node) { + return false; + } + // If focus is in any iframe owned by the host page rather than the + // extension's own surface, bail out so we don't steal native browser + // bindings (Linear-style scoping). + return node.tagName === 'IFRAME'; +}; + +export const Spotlight = ({ + isOpen, + onClose, + onCommandRun, + onOpenViaShortcut, +}: SpotlightDialogProps): ReactElement | null => { + const router = useRouter(); + const { isLoggedIn, showLogin } = useAuthContext(); + const isLaptop = useViewSize(ViewSize.Laptop); + const isMobile = !isLaptop; + const inputRef = useRef(null); + const listRef = useRef(null); + const [resultCount, setResultCount] = useState(null); + const [groupCursor, setGroupCursor] = useState(0); + const [cmdValue, setCmdValue] = useState(''); + const { commands, env } = useSpotlightCommands(); + const { + recent, + refresh: refreshRecent, + push: pushRecent, + } = useRecentCommands(); + const spotlight = useSpotlight(); + const { + query, + setQuery, + pendingConfirmId, + requestConfirm, + clearConfirm, + scope, + pushScope, + popScope, + clearScope, + } = spotlight; + const runCommand = useCallback( + (command: SpotlightCommand) => { + onCommandRun?.(command); + pushRecent(command.id); + Promise.resolve(command.perform()).then( + (result) => { + clearConfirm(); + const keepOpen = + !!result && typeof result === 'object' && result.keepOpen === true; + if (!keepOpen) { + onClose(); + } + }, + (error) => { + clearConfirm(); + onClose(); + throw error; + }, + ); + }, + [onCommandRun, pushRecent, clearConfirm, onClose], + ); + const search = useSpotlightSearchCommands({ router, query }); + useQuickKeyDispatch({ + query, + setQuery, + commands, + scope, + onDispatch: runCommand, + }); + + useEffect(() => { + if (typeof window === 'undefined') { + return undefined; + } + const handleKeydown = (event: KeyboardEvent) => { + if (!isSpecialKeyPressed({ event }) || event.key.toLowerCase() !== 'k') { + return; + } + if (isInExtensionIframe(document.activeElement)) { + return; + } + event.preventDefault(); + if (isOpen) { + onClose(); + return; + } + spotlight.open(); + onOpenViaShortcut?.(); + }; + window.addEventListener('keydown', handleKeydown); + return () => { + window.removeEventListener('keydown', handleKeydown); + }; + }, [isOpen, onClose, spotlight, onOpenViaShortcut]); + + useEffect(() => { + if (isOpen) { + refreshRecent(); + } + }, [isOpen, refreshRecent]); + + // When the query changes or async search results land, anchor the list + // back to the top AND move cmdk's selection onto the new first item. + // Without this: + // - the browser's default `overflow-anchor: auto` keeps the previously- + // selected (synchronous) Action row in view, scrolling People / Squads + // / Tags / Posts results below the fold the instant they arrive; + // - cmdk preserves selection on the old Action row, so arrow keys feel + // "stuck at the bottom" while the visible top is the entity hits. + useEffect(() => { + if (!listRef.current) { + return; + } + listRef.current.scrollTop = 0; + const firstItem = listRef.current.querySelector('[cmdk-item]'); + const nextValue = firstItem?.getAttribute('data-value') ?? ''; + setCmdValue(nextValue); + }, [ + query, + scope, + search.isLoading, + search.users.length, + search.sources.length, + search.tags.length, + search.posts.length, + ]); + + const commandById = useMemo(() => { + const map = new Map(); + commands.forEach((cmd) => map.set(cmd.id, cmd)); + return map; + }, [commands]); + + const grouped = useMemo(() => { + const out: Record = { + [SpotlightGroup.Suggested]: [], + [SpotlightGroup.Recent]: [], + [SpotlightGroup.Navigate]: [], + [SpotlightGroup.Create]: [], + [SpotlightGroup.Settings]: [], + [SpotlightGroup.Actions]: [], + [SpotlightGroup.Search]: [], + [SpotlightGroup.Help]: [], + }; + commands.forEach((cmd) => { + out[cmd.group].push(cmd); + }); + return out; + }, [commands]); + + const trimmedQuery = query.trim(); + const isFiltering = trimmedQuery.length > 0; + + const suggested = useMemo( + () => + SUGGESTED_COMMAND_IDS.map((id) => commandById.get(id)).filter( + (cmd): cmd is SpotlightCommand => !!cmd, + ), + [commandById], + ); + + const recentCommands = useMemo(() => { + if (isFiltering) { + return []; + } + return recent + .map((entry) => commandById.get(entry.commandId)) + .filter((cmd): cmd is SpotlightCommand => !!cmd); + }, [recent, commandById, isFiltering]); + + /** + * Single merged "Suggested actions" bucket when filtering. We keep the + * generic command rows (nav/create/settings/actions/help) under one + * heading at the end so entity results lead and actions trail — the + * order users requested. cmdk still does fuzzy filtering. + */ + const filterableActionCommands = useMemo(() => { + if (!isFiltering) { + return []; + } + return [ + ...grouped[SpotlightGroup.Navigate], + ...grouped[SpotlightGroup.Create], + ...grouped[SpotlightGroup.Settings], + ...grouped[SpotlightGroup.Actions], + ...grouped[SpotlightGroup.Help], + ]; + }, [grouped, isFiltering]); + + /** + * Categories rendered by the Actions browse-mode (Apple-Finder + * equivalent). Order mirrors `groupOrder` so the list matches the + * mental model used elsewhere in the palette. + */ + const actionScopeGroups: SpotlightGroup[] = [ + SpotlightGroup.Navigate, + SpotlightGroup.Create, + SpotlightGroup.Settings, + SpotlightGroup.Actions, + SpotlightGroup.Help, + ]; + + /** + * Which scope chips to surface below the input. Apple Spotlight pattern: + * tabs only appear when there's something to filter through (i.e. a + * query with multi-type results). Idle modal renders nothing here. + */ + const handleSelect = useCallback( + (command: SpotlightCommand) => { + if (command.requiresAuth && !isLoggedIn) { + showLogin({ trigger: AuthTriggers.SearchInput }); + onClose(); + return; + } + if (command.plusBadge && !env.isPlus) { + // Same friendly behavior — bounce to upgrade flow on the Plus page. + router.push('/plus'); + onClose(); + return; + } + if (command.destructive && pendingConfirmId !== command.id) { + requestConfirm(command.id); + return; + } + runCommand(command); + }, + [ + isLoggedIn, + showLogin, + onClose, + env.isPlus, + router, + pendingConfirmId, + requestConfirm, + runCommand, + ], + ); + + const handleFallthroughEnter = useCallback(() => { + if (!trimmedQuery) { + return; + } + const fallthrough = search.fallthrough[0]; + if (fallthrough) { + handleSelect(fallthrough); + } + }, [trimmedQuery, search.fallthrough, handleSelect]); + + /** + * Groups visible right now (skip Suggested when filtering, etc.). Cmd+1..9 + * and Tab navigate between these in display order. + */ + const visibleGroups = useMemo(() => { + if (isFiltering) { + const filterGroups: SpotlightGroup[] = []; + groupOrder.forEach((group) => { + if ( + group === SpotlightGroup.Suggested || + group === SpotlightGroup.Recent + ) { + return; + } + if (grouped[group].length > 0) { + filterGroups.push(group); + } + }); + return filterGroups; + } + const out: SpotlightGroup[] = []; + if (recentCommands.length > 0) { + out.push(SpotlightGroup.Recent); + } + if (suggested.length > 0) { + out.push(SpotlightGroup.Suggested); + } + groupOrder.forEach((group) => { + if ( + group === SpotlightGroup.Suggested || + group === SpotlightGroup.Recent || + group === SpotlightGroup.Search + ) { + return; + } + if (grouped[group].length > 0) { + out.push(group); + } + }); + return out; + }, [isFiltering, grouped, suggested.length, recentCommands.length]); + + const jumpToGroup = useCallback((group: SpotlightGroup) => { + const heading = document.querySelector( + `[cmdk-group][data-spotlight-group="${group}"]`, + ); + if (heading instanceof HTMLElement) { + heading.scrollIntoView({ block: 'start', behavior: 'smooth' }); + } + }, []); + + const pendingCommand = pendingConfirmId + ? commandById.get(pendingConfirmId) + : null; + + if (!isOpen) { + return null; + } + + const commonRowProps = { + isLoggedIn, + isPlus: env.isPlus, + isMobile, + pendingConfirmId, + onSelect: handleSelect, + }; + + const paletteBody = ( + <> +

+ Spotlight +

+
+ {resultCount !== null + ? `${resultCount} ${resultCount === 1 ? 'result' : 'results'}` + : ''} +
+ { + if (event.key === 'Escape') { + if (pendingConfirmId) { + event.preventDefault(); + event.stopPropagation(); + clearConfirm(); + return; + } + } + if ( + event.altKey && + event.key >= '1' && + event.key <= '5' && + !pendingConfirmId + ) { + const idx = Number(event.key) - 1; + const targetScope = scopeOrder[idx]; + if (targetScope) { + event.preventDefault(); + event.stopPropagation(); + pushScope(targetScope); + } + return; + } + if ( + (event.metaKey || event.ctrlKey) && + event.key >= '1' && + event.key <= '9' + ) { + const idx = Number(event.key) - 1; + const target = visibleGroups[idx]; + if (target) { + event.preventDefault(); + setGroupCursor(idx); + jumpToGroup(target); + } + return; + } + if (event.key === 'Tab' && visibleGroups.length > 0) { + event.preventDefault(); + const direction = event.shiftKey ? -1 : 1; + const nextIdx = + (groupCursor + direction + visibleGroups.length) % + visibleGroups.length; + setGroupCursor(nextIdx); + jumpToGroup(visibleGroups[nextIdx]); + } + }} + > + {!pendingCommand && ( + <> +
+ + {scope !== SpotlightScope.All && ( + + )} + { + if ( + event.key === 'Backspace' && + query.length === 0 && + scope !== SpotlightScope.All + ) { + event.preventDefault(); + popScope(); + return; + } + if ( + event.key === 'Enter' && + isFiltering && + resultCount === 0 + ) { + event.preventDefault(); + handleFallthroughEnter(); + } + }} + /> + {search.isLoading && } + {query && ( + + )} +
+ {scope === SpotlightScope.All && ( + + )} + + )} + + {pendingCommand && ( + handleSelect(pendingCommand)} + /> + )} + + {!pendingCommand && ( + { + listRef.current = node; + if (!node) { + setResultCount(null); + return; + } + const items = node.querySelectorAll('[data-command-id]'); + setResultCount(items.length); + }} + > + {scope !== SpotlightScope.All && + scope !== SpotlightScope.Actions && + search.isLoading && ( + + + + )} + + {scope !== SpotlightScope.All && + scope !== SpotlightScope.Actions && + !search.isLoading && ( + + {renderRows({ + ...commonRowProps, + commands: (() => { + if (scope === SpotlightScope.Posts) { + return search.posts; + } + if (scope === SpotlightScope.Squads) { + return search.sources; + } + if (scope === SpotlightScope.People) { + return search.users; + } + return search.tags; + })(), + })} + + )} + + {/* + Actions browse-mode (Apple-Finder "All Apps" equivalent). + Renders every visible action command grouped by category; + cmdk's strict filter narrows the list as the user types. + Empty query = full catalog of actions, exactly what the + user asked for ("see all the options in the product"). + */} + {scope === SpotlightScope.Actions && + actionScopeGroups.map((group) => ( + + {renderRows({ ...commonRowProps, commands: grouped[group] })} + + ))} + + {scope === SpotlightScope.All && + !isFiltering && + recentCommands.length > 0 && ( + + {renderRows({ ...commonRowProps, commands: recentCommands })} + + )} + + {scope === SpotlightScope.All && + !isFiltering && + suggested.length > 0 && ( + + {renderRows({ ...commonRowProps, commands: suggested })} + + )} + + {scope === SpotlightScope.All && + !isFiltering && + suggested.length === 0 && + recentCommands.length === 0 && ( +
+

+ Type to search posts, squads, people, tags, or browse all + actions. +

+
+ )} + + {scope === SpotlightScope.All && + isFiltering && + search.isLoading && ( + + + + )} + + {scope === SpotlightScope.All && + isFiltering && + !search.isLoading && + search.users.length > 0 && ( + + {renderRows({ ...commonRowProps, commands: search.users })} + + )} + + {scope === SpotlightScope.All && + isFiltering && + !search.isLoading && + search.sources.length > 0 && ( + + {renderRows({ ...commonRowProps, commands: search.sources })} + + )} + + {scope === SpotlightScope.All && + isFiltering && + !search.isLoading && + search.tags.length > 0 && ( + + {renderRows({ ...commonRowProps, commands: search.tags })} + + )} + + {scope === SpotlightScope.All && + isFiltering && + !search.isLoading && + search.posts.length > 0 && ( + + {renderRows({ ...commonRowProps, commands: search.posts })} + + )} + + {scope === SpotlightScope.All && + isFiltering && + filterableActionCommands.length > 0 && ( + + {renderRows({ + ...commonRowProps, + commands: filterableActionCommands, + })} + + )} + + {scope === SpotlightScope.All && + isFiltering && + search.fallthrough.length > 0 && ( + + {renderRows({ + ...commonRowProps, + commands: search.fallthrough, + })} + + )} + + + +

+ {trimmedQuery + ? `Nothing matches "${trimmedQuery}".` + : 'No commands available.'} +

+

+ Try a different word, or search posts on the web. +

+ {trimmedQuery && ( + + )} +
+
+ )} + +
+ + + + +
+
+ + ); + + if (isMobile) { + return ( + + {paletteBody} + + ); + } + + return ( + + {paletteBody} + + ); +}; + +export default Spotlight; diff --git a/packages/shared/src/components/spotlight/SpotlightContext.tsx b/packages/shared/src/components/spotlight/SpotlightContext.tsx new file mode 100644 index 00000000000..ac5dd2afaf0 --- /dev/null +++ b/packages/shared/src/components/spotlight/SpotlightContext.tsx @@ -0,0 +1,227 @@ +import type { ReactElement, ReactNode } from 'react'; +import React, { + createContext, + useCallback, + useContext, + useMemo, + useState, +} from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { gqlClient } from '../../graphql/common'; +import { isExtension } from '../../lib/func'; +import { useAuthContext } from '../../contexts/AuthContext'; +import { usePlusSubscription } from '../../hooks/usePlusSubscription'; +import { + SPOTLIGHT_ACTIONS_QUERY, + type SpotlightAction, +} from '../../graphql/spotlight'; +import { SpotlightScope } from './types'; + +type SpotlightActionsResponse = { spotlightActions: SpotlightAction[] }; + +export const SPOTLIGHT_ACTIONS_QUERY_KEY = ['spotlight', 'actions']; + +const platformId = isExtension ? 'extension' : 'webapp'; + +export interface SpotlightContextValue { + isOpen: boolean; + query: string; + /** Inline destructive-confirm gate: the command id awaiting confirm. */ + pendingConfirmId: string | null; + /** + * cmdk-style pages stack for scope filtering. Empty stack = `All`. Top of + * the stack is the active scope. Backspace on empty input pops the top. + */ + pages: SpotlightScope[]; + /** Convenience accessor for the active scope (`All` when stack is empty). */ + scope: SpotlightScope; + open: () => void; + close: () => void; + toggle: () => void; + setQuery: (value: string) => void; + requestConfirm: (commandId: string) => void; + clearConfirm: () => void; + /** Open the modal pre-scoped to a specific entity type. */ + openWithScope: (scope: SpotlightScope) => void; + /** Push a scope page onto the stack while the modal is already open. */ + pushScope: (scope: SpotlightScope) => void; + /** Pop the top scope page (no-op when already at `All`). */ + popScope: () => void; + /** Reset the stack to `All`. */ + clearScope: () => void; + /** Action catalog from the API, filtered by current user's auth/plus/platform. */ + actions: SpotlightAction[]; + isActionsLoading: boolean; +} + +export const SpotlightContext = createContext( + null, +); + +interface SpotlightProviderProps { + children: ReactNode; +} + +export const SpotlightProvider = ({ + children, +}: SpotlightProviderProps): ReactElement => { + const [isOpen, setIsOpen] = useState(false); + const [query, setQueryState] = useState(''); + const [pendingConfirmId, setPendingConfirmId] = useState(null); + const [pages, setPages] = useState([]); + + const { isLoggedIn } = useAuthContext(); + const { isPlus } = usePlusSubscription(); + const { data: rawActions, isPending: isActionsLoading } = useQuery({ + queryKey: SPOTLIGHT_ACTIONS_QUERY_KEY, + queryFn: async () => { + const result = await gqlClient.request( + SPOTLIGHT_ACTIONS_QUERY, + ); + return result.spotlightActions; + }, + staleTime: Infinity, + gcTime: Infinity, + refetchOnMount: false, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + }); + + const actions = useMemo(() => { + return (rawActions ?? []).filter((action) => { + if (action.requiresAuth && !isLoggedIn) { + return false; + } + if (action.requiresPlus && !isPlus) { + return false; + } + if (action.platforms?.length && !action.platforms.includes(platformId)) { + return false; + } + return true; + }); + }, [rawActions, isLoggedIn, isPlus]); + + const open = useCallback(() => { + setIsOpen(true); + }, []); + + const close = useCallback(() => { + setIsOpen(false); + setQueryState(''); + setPendingConfirmId(null); + setPages([]); + }, []); + + const toggle = useCallback(() => { + setIsOpen((prev) => { + if (prev) { + setQueryState(''); + setPendingConfirmId(null); + setPages([]); + } + return !prev; + }); + }, []); + + const setQuery = useCallback((value: string) => { + setQueryState(value); + setPendingConfirmId(null); + }, []); + + const requestConfirm = useCallback((commandId: string) => { + setPendingConfirmId(commandId); + }, []); + + const clearConfirm = useCallback(() => { + setPendingConfirmId(null); + }, []); + + const openWithScope = useCallback((next: SpotlightScope) => { + setPages(next === SpotlightScope.All ? [] : [next]); + setQueryState(''); + setPendingConfirmId(null); + setIsOpen(true); + }, []); + + const pushScope = useCallback((next: SpotlightScope) => { + // Keep the active query when narrowing scope. The chip is a filter, not + // a fresh search — clearing the query here would empty the entity lists + // (search hooks return nothing without a query) and the user would see + // a blank list right after clicking the very chip that promised matches. + setPages((prev) => { + if (next === SpotlightScope.All) { + return []; + } + if (prev[prev.length - 1] === next) { + return prev; + } + return [...prev, next]; + }); + }, []); + + const popScope = useCallback(() => { + setPages((prev) => prev.slice(0, -1)); + }, []); + + const clearScope = useCallback(() => { + setPages([]); + }, []); + + const scope = pages[pages.length - 1] ?? SpotlightScope.All; + + const value = useMemo( + () => ({ + isOpen, + query, + pendingConfirmId, + pages, + scope, + open, + close, + toggle, + setQuery, + requestConfirm, + clearConfirm, + openWithScope, + pushScope, + popScope, + clearScope, + actions, + isActionsLoading, + }), + [ + isOpen, + query, + pendingConfirmId, + pages, + scope, + open, + close, + toggle, + setQuery, + requestConfirm, + clearConfirm, + openWithScope, + pushScope, + popScope, + clearScope, + actions, + isActionsLoading, + ], + ); + + return ( + + {children} + + ); +}; + +export const useSpotlight = (): SpotlightContextValue => { + const ctx = useContext(SpotlightContext); + if (!ctx) { + throw new Error('useSpotlight must be used within SpotlightProvider'); + } + return ctx; +}; diff --git a/packages/shared/src/components/spotlight/SpotlightHost.tsx b/packages/shared/src/components/spotlight/SpotlightHost.tsx new file mode 100644 index 00000000000..13ce123cefa --- /dev/null +++ b/packages/shared/src/components/spotlight/SpotlightHost.tsx @@ -0,0 +1,60 @@ +import type { ReactElement } from 'react'; +import React, { useCallback } from 'react'; +import dynamic from 'next/dynamic'; +import { useLogContext } from '../../contexts/LogContext'; +import useLogEventOnce from '../../hooks/log/useLogEventOnce'; +import { LogEvent, TargetId, TargetType } from '../../lib/log'; +import { useSpotlight } from './SpotlightContext'; +import type { SpotlightCommand } from './types'; + +const Spotlight = dynamic( + () => import(/* webpackChunkName: "spotlight" */ './Spotlight'), + { ssr: false }, +); + +/** + * Mounts the Spotlight dialog globally and owns its telemetry. Impression + * logs once per session via {@link useLogEventOnce}; per-command engagement + * is tracked separately via the click event below. + */ +export const SpotlightHost = (): ReactElement => { + const { logEvent } = useLogContext(); + const { isOpen, close } = useSpotlight(); + + useLogEventOnce( + () => ({ + event_name: LogEvent.Impression, + target_type: TargetType.Spotlight, + }), + { condition: isOpen }, + ); + + const handleOpenViaShortcut = useCallback(() => { + logEvent({ + event_name: LogEvent.KeyboardShortcutTriggered, + target_id: TargetId.SpotlightOpen, + }); + }, [logEvent]); + + const handleCommandRun = useCallback( + (command: SpotlightCommand) => { + logEvent({ + event_name: LogEvent.Click, + target_type: TargetType.SpotlightCommand, + target_id: command.id, + }); + }, + [logEvent], + ); + + return ( + + ); +}; + +export default SpotlightHost; diff --git a/packages/shared/src/components/spotlight/SpotlightTrigger.tsx b/packages/shared/src/components/spotlight/SpotlightTrigger.tsx new file mode 100644 index 00000000000..79c8b4f6e33 --- /dev/null +++ b/packages/shared/src/components/spotlight/SpotlightTrigger.tsx @@ -0,0 +1,77 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { AiIcon, SearchIcon } from '../icons'; +import { IconSize } from '../Icon'; +import { isAppleDevice } from '../../lib/func'; +import { KeyboadShortcutLabel } from '../KeyboardShortcutLabel'; +import { useSpotlight } from './SpotlightContext'; +import { ViewSize, useViewSize } from '../../hooks'; + +interface SpotlightTriggerProps { + className?: string; +} + +const shortcutKeys = [isAppleDevice() ? '⌘' : 'Ctrl', 'K']; + +/** + * Header pill that lives where the old SearchPanel input used to. The + * resting visual is a 1:1 match for the production SearchPanelInput + * (`BaseField` + `AiIcon` + `KeyboadShortcutLabel`) so users see no + * difference until they actually click and the Spotlight modal opens. + */ +export const SpotlightTrigger = ({ + className, +}: SpotlightTriggerProps): ReactElement => { + const { open } = useSpotlight(); + const isLaptop = useViewSize(ViewSize.Laptop); + + if (!isLaptop) { + return ( + + ); + } + + return ( + + ); +}; + +export default SpotlightTrigger; diff --git a/packages/shared/src/components/spotlight/commands/search.ts b/packages/shared/src/components/spotlight/commands/search.ts new file mode 100644 index 00000000000..081039b4c28 --- /dev/null +++ b/packages/shared/src/components/spotlight/commands/search.ts @@ -0,0 +1,253 @@ +import { useMemo } from 'react'; +import type { NextRouter } from 'next/router'; +import { HashtagIcon, OpenLinkIcon, SearchIcon } from '../../icons'; +import { + SearchProviderEnum, + getSearchUrl, + minSearchQueryLength, + type SearchSuggestion, +} from '../../../graphql/search'; +import { useSearchProviderSuggestions } from '../../../hooks/search'; +import { webappUrl } from '../../../lib/constants'; +import { + SpotlightGroup, + SpotlightScope, + type SpotlightCommand, +} from '../types'; + +interface SearchCommandsContext { + router: Pick; + query: string; +} + +export interface SpotlightSearchCommands { + isLoading: boolean; + posts: SpotlightCommand[]; + tags: SpotlightCommand[]; + sources: SpotlightCommand[]; + users: SpotlightCommand[]; + /** Always-on fallthrough rows, shown even while suggestions are loading. */ + fallthrough: SpotlightCommand[]; +} + +const buildPostCommand = ( + hit: SearchSuggestion, + router: SearchCommandsContext['router'], +): SpotlightCommand => ({ + id: `search.post.${hit.id ?? hit.title}`, + title: hit.title, + subtitle: hit.subtitle, + icon: SearchIcon, + group: SpotlightGroup.Search, + meta: { + kind: 'post', + sourceImage: hit.image, + sourceName: hit.subtitle, + }, + perform: () => { + if (hit.id) { + router.push(`${webappUrl}posts/${hit.id}`); + return; + } + router.push( + getSearchUrl({ query: hit.title, provider: SearchProviderEnum.Posts }), + ); + }, +}); + +const buildTagCommand = ( + hit: SearchSuggestion, + router: SearchCommandsContext['router'], +): SpotlightCommand => ({ + id: `search.tag.${hit.id ?? hit.title}`, + title: hit.title, + subtitle: hit.subtitle, + icon: HashtagIcon, + group: SpotlightGroup.Search, + meta: { kind: 'tag', tagName: hit.title }, + perform: () => { + router.push(`${webappUrl}tags/${hit.title}`); + }, +}); + +const buildSourceCommand = ( + hit: SearchSuggestion, + router: SearchCommandsContext['router'], +): SpotlightCommand => ({ + id: `search.source.${hit.id ?? hit.title}`, + title: hit.title, + subtitle: hit.subtitle, + icon: SearchIcon, + group: SpotlightGroup.Search, + meta: { + kind: 'source', + image: hit.image, + handle: hit.subtitle, + }, + perform: () => { + if (!hit.id) { + return; + } + router.push(`${webappUrl}sources/${hit.id}`); + }, +}); + +const buildUserCommand = ( + hit: SearchSuggestion, + router: SearchCommandsContext['router'], +): SpotlightCommand => ({ + id: `search.user.${hit.id ?? hit.title}`, + title: hit.title, + subtitle: hit.subtitle, + icon: SearchIcon, + group: SpotlightGroup.Search, + meta: { + kind: 'user', + image: hit.image, + handle: hit.subtitle, + }, + perform: () => { + if (!hit.subtitle) { + return; + } + // `subtitle` carries the @username for user suggestions. + const handle = hit.subtitle.startsWith('@') + ? hit.subtitle.slice(1) + : hit.subtitle; + router.push(`${webappUrl}${handle}`); + }, +}); + +type SeeAllScope = Exclude< + SpotlightScope, + SpotlightScope.All | SpotlightScope.Actions +>; + +const seeAllProvider: Record = { + [SpotlightScope.Posts]: SearchProviderEnum.Posts, + [SpotlightScope.Squads]: SearchProviderEnum.Sources, + [SpotlightScope.People]: SearchProviderEnum.Users, + [SpotlightScope.Tags]: SearchProviderEnum.Tags, +}; + +const seeAllLabel: Record = { + [SpotlightScope.Posts]: 'posts', + [SpotlightScope.Squads]: 'squads', + [SpotlightScope.People]: 'people', + [SpotlightScope.Tags]: 'tags', +}; + +const buildSeeAllCommand = ( + scope: SeeAllScope, + query: string, + router: SearchCommandsContext['router'], +): SpotlightCommand => ({ + id: `search.see-all.${scope}`, + title: `See all ${seeAllLabel[scope]} for "${query}"`, + icon: OpenLinkIcon, + group: SpotlightGroup.Search, + meta: { kind: 'see-all', scope }, + perform: () => { + router.push(getSearchUrl({ query, provider: seeAllProvider[scope] })); + }, +}); + +/** + * Always-on rows that act as the no-results escape hatch and explicit + * search-provider entry points. Visible whenever there's a query. + */ +const buildFallthrough = ( + query: string, + router: SearchCommandsContext['router'], +): SpotlightCommand[] => { + const trimmed = query.trim(); + if (!trimmed) { + return []; + } + return [ + { + id: 'search.fallthrough.posts', + title: `Search posts for "${trimmed}"`, + icon: SearchIcon, + group: SpotlightGroup.Search, + perform: () => { + router.push( + getSearchUrl({ query: trimmed, provider: SearchProviderEnum.Posts }), + ); + }, + }, + ]; +}; + +export const useSpotlightSearchCommands = ({ + router, + query, +}: SearchCommandsContext): SpotlightSearchCommands => { + const trimmed = query.trim(); + + const { suggestions: postHits, isLoading: postsLoading } = + useSearchProviderSuggestions({ + provider: SearchProviderEnum.Posts, + query: trimmed, + }); + const { suggestions: tagHits, isLoading: tagsLoading } = + useSearchProviderSuggestions({ + provider: SearchProviderEnum.Tags, + query: trimmed, + }); + const { suggestions: sourceHits, isLoading: sourcesLoading } = + useSearchProviderSuggestions({ + provider: SearchProviderEnum.Sources, + query: trimmed, + }); + const { suggestions: userHits, isLoading: usersLoading } = + useSearchProviderSuggestions({ + provider: SearchProviderEnum.Users, + query: trimmed, + }); + + return useMemo(() => { + const posts = (postHits?.hits ?? []).map((hit) => + buildPostCommand(hit, router), + ); + const tags = (tagHits?.hits ?? []).map((hit) => + buildTagCommand(hit, router), + ); + const sources = (sourceHits?.hits ?? []).map((hit) => + buildSourceCommand(hit, router), + ); + const users = (userHits?.hits ?? []).map((hit) => + buildUserCommand(hit, router), + ); + + const withSeeAll = ( + scope: SeeAllScope, + items: SpotlightCommand[], + ): SpotlightCommand[] => + items.length > 0 && trimmed + ? [...items, buildSeeAllCommand(scope, trimmed, router)] + : items; + + return { + isLoading: + trimmed.length >= minSearchQueryLength && + (postsLoading || tagsLoading || sourcesLoading || usersLoading), + posts: withSeeAll(SpotlightScope.Posts, posts), + tags: withSeeAll(SpotlightScope.Tags, tags), + sources: withSeeAll(SpotlightScope.Squads, sources), + users: withSeeAll(SpotlightScope.People, users), + fallthrough: buildFallthrough(trimmed, router), + }; + }, [ + trimmed, + router, + postHits, + postsLoading, + tagHits, + tagsLoading, + sourceHits, + sourcesLoading, + userHits, + usersLoading, + ]); +}; diff --git a/packages/shared/src/components/spotlight/dispatcher.spec.ts b/packages/shared/src/components/spotlight/dispatcher.spec.ts new file mode 100644 index 00000000000..6b24f86cbe5 --- /dev/null +++ b/packages/shared/src/components/spotlight/dispatcher.spec.ts @@ -0,0 +1,173 @@ +import { LazyModal } from '../modals/common/types'; +import { ThemeMode } from '../../contexts/SettingsContext'; +import { + type SpotlightAction, + SpotlightActionGroup, + SpotlightActionKind, +} from '../../graphql/spotlight'; +import { + dispatchSpotlightAction, + type SpotlightDispatchDeps, +} from './dispatcher'; +import { SpotlightScope } from './types'; + +const buildAction = (overrides: Partial): SpotlightAction => ({ + id: 'test-action', + group: SpotlightActionGroup.Actions, + title: 'Test', + icon: 'Plus', + keywords: [], + kind: SpotlightActionKind.Navigate, + payload: {}, + ...overrides, +}); + +const buildDeps = ( + overrides: Partial = {}, +): SpotlightDispatchDeps => ({ + router: { push: jest.fn() }, + openModal: jest.fn(), + settings: { + themeMode: ThemeMode.Dark, + insaneMode: false, + setTheme: jest.fn().mockResolvedValue(undefined), + toggleSidebarExpanded: jest.fn().mockResolvedValue(undefined), + toggleInsaneMode: jest.fn().mockResolvedValue(undefined), + } as unknown as SpotlightDispatchDeps['settings'], + user: { username: 'foo' }, + logout: jest.fn().mockResolvedValue(undefined), + pushScope: jest.fn(), + ...overrides, +}); + +/* eslint-disable no-template-curly-in-string -- ${username} is a literal path token resolved by the Navigate dispatcher. */ + +describe('dispatchSpotlightAction', () => { + it('opens a modal by LazyModal key', async () => { + const deps = buildDeps(); + await dispatchSpotlightAction( + buildAction({ + kind: SpotlightActionKind.OpenModal, + payload: { modal: 'NewSource', props: { x: 1 } }, + }), + deps, + ); + expect(deps.openModal).toHaveBeenCalledWith({ + type: LazyModal.NewSource, + props: { x: 1 }, + }); + }); + + it('rejects unknown modal keys', async () => { + await expect( + dispatchSpotlightAction( + buildAction({ + kind: SpotlightActionKind.OpenModal, + payload: { modal: 'NotARealModal' }, + }), + buildDeps(), + ), + ).rejects.toThrow(/unknown modal/); + }); + + it('routes Navigate via router.push with absolute webapp URL prefix', async () => { + const deps = buildDeps(); + await dispatchSpotlightAction( + buildAction({ + kind: SpotlightActionKind.Navigate, + payload: { path: '/bookmarks' }, + }), + deps, + ); + expect(deps.router.push).toHaveBeenCalledTimes(1); + expect((deps.router.push as jest.Mock).mock.calls[0][0]).toMatch( + /bookmarks$/, + ); + }); + + it('substitutes ${username} in Navigate paths', async () => { + const deps = buildDeps(); + await dispatchSpotlightAction( + buildAction({ + kind: SpotlightActionKind.Navigate, + payload: { path: '/${username}' }, + }), + deps, + ); + expect((deps.router.push as jest.Mock).mock.calls[0][0]).toMatch(/\/foo$/); + }); + + it('throws when a Navigate path token cannot be resolved', async () => { + const deps = buildDeps({ user: null }); + await expect( + dispatchSpotlightAction( + buildAction({ + kind: SpotlightActionKind.Navigate, + payload: { path: '/${username}' }, + }), + deps, + ), + ).rejects.toThrow(/missing token "username"/); + }); + + it('toggles theme via settings.setTheme', async () => { + const deps = buildDeps(); + await dispatchSpotlightAction( + buildAction({ + kind: SpotlightActionKind.ToggleSetting, + payload: { key: 'theme' }, + }), + deps, + ); + expect(deps.settings.setTheme).toHaveBeenCalledWith(ThemeMode.Light); + }); + + it('rejects unknown setting key', async () => { + await expect( + dispatchSpotlightAction( + buildAction({ + kind: SpotlightActionKind.ToggleSetting, + payload: { key: 'whatever' }, + }), + buildDeps(), + ), + ).rejects.toThrow(/invalid payload for ToggleSetting/); + }); + + it('maps search handlers to scope pushes and keeps Spotlight open', async () => { + const deps = buildDeps(); + const result = await dispatchSpotlightAction( + buildAction({ + kind: SpotlightActionKind.RunClientAction, + payload: { handlerId: 'searchTags' }, + }), + deps, + ); + expect(deps.pushScope).toHaveBeenCalledWith(SpotlightScope.Tags); + expect(result).toEqual({ keepOpen: true }); + }); + + it('runs logout via deps.logout', async () => { + const deps = buildDeps(); + await dispatchSpotlightAction( + buildAction({ + kind: SpotlightActionKind.RunClientAction, + payload: { handlerId: 'logout' }, + }), + deps, + ); + expect(deps.logout).toHaveBeenCalled(); + }); + + it('rejects unknown client handler', async () => { + await expect( + dispatchSpotlightAction( + buildAction({ + kind: SpotlightActionKind.RunClientAction, + payload: { handlerId: 'noSuchHandler' }, + }), + buildDeps(), + ), + ).rejects.toThrow(/unknown client handler/); + }); +}); diff --git a/packages/shared/src/components/spotlight/dispatcher.ts b/packages/shared/src/components/spotlight/dispatcher.ts new file mode 100644 index 00000000000..7cc8ba4f619 --- /dev/null +++ b/packages/shared/src/components/spotlight/dispatcher.ts @@ -0,0 +1,219 @@ +import type { NextRouter } from 'next/router'; +import { z } from 'zod'; +import type { LoggedUser } from '../../lib/user'; +import { LazyModal } from '../modals/common/types'; +import type { LazyModalType } from '../modals/common'; +import type { SettingsContextData } from '../../contexts/SettingsContext'; +import { ThemeMode } from '../../contexts/SettingsContext'; +import { webappUrl } from '../../lib/constants'; +import { + type SpotlightAction, + SpotlightActionKind, +} from '../../graphql/spotlight'; +import { SpotlightScope, type SpotlightCommandResult } from './types'; + +export type SpotlightDispatchDeps = { + router: Pick; + openModal: (input: LazyModalType) => void; + settings: SettingsContextData; + user?: Pick | null; + logout: (reason: string) => Promise | void; + pushScope: (scope: SpotlightScope) => void; +}; + +const openModalPayloadSchema = z.object({ + modal: z.string(), + props: z.record(z.string(), z.unknown()).optional(), +}); + +const openUrlPayloadSchema = z.object({ + url: z.url(), + external: z.boolean().optional(), +}); + +const navigatePayloadSchema = z.object({ + path: z.string().min(1), +}); + +const toggleSettingPayloadSchema = z.object({ + key: z.enum(['theme', 'sidebar', 'insaneMode']), +}); + +const runClientActionPayloadSchema = z.object({ + handlerId: z.string().min(1), +}); + +const SCOPE_BY_HANDLER: Record = { + searchPosts: SpotlightScope.Posts, + searchSources: SpotlightScope.Squads, + searchTags: SpotlightScope.Tags, + searchUsers: SpotlightScope.People, +}; + +const isLazyModalKey = (value: string): value is keyof typeof LazyModal => + Object.prototype.hasOwnProperty.call(LazyModal, value); + +const parsePayload = ( + kind: SpotlightActionKind, + schema: S, + payload: unknown, +): z.infer => { + const result = schema.safeParse(payload); + if (!result.success) { + throw new Error( + `Spotlight: invalid payload for ${kind} (${result.error.message})`, + ); + } + return result.data; +}; + +const toggleTheme = async (settings: SettingsContextData): Promise => { + const next = + settings.themeMode === ThemeMode.Dark ? ThemeMode.Light : ThemeMode.Dark; + await settings.setTheme(next); +}; + +const dispatchOpenModal = ( + payload: unknown, + deps: SpotlightDispatchDeps, +): void => { + const { modal, props } = parsePayload( + SpotlightActionKind.OpenModal, + openModalPayloadSchema, + payload, + ); + if (!isLazyModalKey(modal)) { + throw new Error(`Spotlight: unknown modal "${modal}"`); + } + deps.openModal({ + type: LazyModal[modal], + props: props ?? {}, + } as LazyModalType); +}; + +const dispatchOpenUrl = ( + payload: unknown, + deps: SpotlightDispatchDeps, +): void => { + const { url, external } = parsePayload( + SpotlightActionKind.OpenUrl, + openUrlPayloadSchema, + payload, + ); + if (external) { + if (typeof window !== 'undefined') { + window.open(url, '_blank', 'noopener,noreferrer'); + } + return; + } + deps.router.push(url); +}; + +const PATH_TOKEN_PATTERN = /\$\{(\w+)\}/g; + +const resolvePathTokens = ( + path: string, + deps: SpotlightDispatchDeps, +): string => { + const tokens: Record = { + username: deps.user?.username, + }; + return path.replace(PATH_TOKEN_PATTERN, (_, key: string) => { + const value = tokens[key]; + if (!value) { + throw new Error(`Spotlight: missing token "${key}" for Navigate path`); + } + return value; + }); +}; + +const dispatchNavigate = ( + payload: unknown, + deps: SpotlightDispatchDeps, +): void => { + const { path: rawPath } = parsePayload( + SpotlightActionKind.Navigate, + navigatePayloadSchema, + payload, + ); + const path = resolvePathTokens(rawPath, deps); + const isAbsolute = /^https?:\/\//.test(path); + deps.router.push( + isAbsolute ? path : `${webappUrl}${path.replace(/^\//, '')}`, + ); +}; + +const dispatchToggleSetting = async ( + payload: unknown, + deps: SpotlightDispatchDeps, +): Promise => { + const { key } = parsePayload( + SpotlightActionKind.ToggleSetting, + toggleSettingPayloadSchema, + payload, + ); + switch (key) { + case 'theme': + await toggleTheme(deps.settings); + return; + case 'sidebar': + await deps.settings.toggleSidebarExpanded(); + return; + case 'insaneMode': + await deps.settings.toggleInsaneMode(!deps.settings.insaneMode); + return; + default: { + const exhaustive: never = key; + throw new Error(`Spotlight: unhandled setting key "${exhaustive}"`); + } + } +}; + +const dispatchRunClientAction = async ( + payload: unknown, + deps: SpotlightDispatchDeps, +): Promise => { + const { handlerId } = parsePayload( + SpotlightActionKind.RunClientAction, + runClientActionPayloadSchema, + payload, + ); + + const scope = SCOPE_BY_HANDLER[handlerId]; + if (scope) { + deps.pushScope(scope); + return { keepOpen: true }; + } + + switch (handlerId) { + case 'logout': + await deps.logout('spotlight'); + return undefined; + default: + throw new Error(`Spotlight: unknown client handler "${handlerId}"`); + } +}; + +export const dispatchSpotlightAction = async ( + action: SpotlightAction, + deps: SpotlightDispatchDeps, +): Promise => { + switch (action.kind) { + case SpotlightActionKind.OpenModal: + dispatchOpenModal(action.payload, deps); + return undefined; + case SpotlightActionKind.OpenUrl: + dispatchOpenUrl(action.payload, deps); + return undefined; + case SpotlightActionKind.Navigate: + dispatchNavigate(action.payload, deps); + return undefined; + case SpotlightActionKind.ToggleSetting: + await dispatchToggleSetting(action.payload, deps); + return undefined; + case SpotlightActionKind.RunClientAction: + return dispatchRunClientAction(action.payload, deps); + default: + throw new Error(`Spotlight: unknown action kind "${action.kind}"`); + } +}; diff --git a/packages/shared/src/components/spotlight/iconRegistry.ts b/packages/shared/src/components/spotlight/iconRegistry.ts new file mode 100644 index 00000000000..89c7d31ef07 --- /dev/null +++ b/packages/shared/src/components/spotlight/iconRegistry.ts @@ -0,0 +1,13 @@ +import type { ComponentType } from 'react'; +import * as IconExports from '../icons'; +import type { IconProps } from '../Icon'; + +export type SpotlightIcon = ComponentType; + +// SpotlightAction.icon stores the fully-qualified export name from the +// icons barrel (e.g. "PlusIcon"). Any icon added to the barrel is +// immediately usable from a seed row — no frontend deploy. +const ICONS = IconExports as unknown as Record; + +export const resolveSpotlightIcon = (name: string): SpotlightIcon => + ICONS[name] ?? ICONS.InfoIcon; diff --git a/packages/shared/src/components/spotlight/spotlightFilter.ts b/packages/shared/src/components/spotlight/spotlightFilter.ts new file mode 100644 index 00000000000..c8e6a2c01cf --- /dev/null +++ b/packages/shared/src/components/spotlight/spotlightFilter.ts @@ -0,0 +1,23 @@ +/** + * cmdk's `filter` prop. We use plain substring matching because the + * catalog is small and the user expectation is predictable: if a title + * contains the query, it shows up. Backend-ranked rows (post / source / + * user / tag search results) opt out via {@link SPOTLIGHT_PASSTHROUGH_KEYWORD} + * so we don't second-guess the API's relevance. + */ +export const SPOTLIGHT_PASSTHROUGH_KEYWORD = '__spotlight_passthrough__'; + +export const spotlightCommandFilter = ( + value: string, + search: string, + keywords?: string[], +): number => { + if (keywords?.includes(SPOTLIGHT_PASSTHROUGH_KEYWORD)) { + return 1; + } + const query = search.trim().toLowerCase(); + if (!query) { + return 1; + } + return value.toLowerCase().includes(query) ? 1 : 0; +}; diff --git a/packages/shared/src/components/spotlight/types.ts b/packages/shared/src/components/spotlight/types.ts new file mode 100644 index 00000000000..b9f1e913e47 --- /dev/null +++ b/packages/shared/src/components/spotlight/types.ts @@ -0,0 +1,196 @@ +import type { ComponentType, ReactElement } from 'react'; +import type { IconProps } from '../Icon'; +import { SearchProviderEnum } from '../../graphql/search'; + +/** + * Verb-prefix convention for command titles (Linear pattern): + * - `Go to …` → navigation + * - `Open …` → modal opener + * - `Switch to …` → mode swap (theme, layout) + * - `Toggle …` → boolean preference + * - `Create …` → new entity + * - `Copy …` → clipboard + * - `Search …` → entity-search providers + * - destructive verbs (`Log out`, `Delete …`) reserved for the + * inline destructive-confirm path. + */ +export enum SpotlightGroup { + Suggested = 'suggested', + Recent = 'recent', + Navigate = 'navigate', + Create = 'create', + Settings = 'settings', + Actions = 'actions', + Search = 'search', + Help = 'help', +} + +export const groupLabels: Record = { + [SpotlightGroup.Suggested]: 'Suggested', + [SpotlightGroup.Recent]: 'Recently used', + [SpotlightGroup.Navigate]: 'Go to', + [SpotlightGroup.Create]: 'Create', + [SpotlightGroup.Settings]: 'Settings', + [SpotlightGroup.Actions]: 'Actions', + [SpotlightGroup.Search]: 'Search', + [SpotlightGroup.Help]: 'Help', +}; + +export const groupOrder: SpotlightGroup[] = [ + SpotlightGroup.Recent, + SpotlightGroup.Suggested, + SpotlightGroup.Navigate, + SpotlightGroup.Create, + SpotlightGroup.Settings, + SpotlightGroup.Actions, + SpotlightGroup.Search, + SpotlightGroup.Help, +]; + +export interface SpotlightContextEnv { + isLoggedIn: boolean; + isAuthReady: boolean; + isPlus: boolean; + isExtension: boolean; + isMobile: boolean; +} + +/** + * Discriminated metadata that lets the UI render typed result rows + * (post / source / user / tag / see-all) instead of a single generic + * row layout. Pure data — never read by `perform`. + */ +export type SpotlightCommandMeta = + | { kind: 'post'; sourceImage?: string; sourceName?: string } + | { kind: 'source'; image?: string; handle?: string } + | { kind: 'user'; image?: string; handle?: string } + | { kind: 'tag'; tagName: string } + | { + kind: 'see-all'; + scope: Exclude< + SpotlightScope, + SpotlightScope.All | SpotlightScope.Actions + >; + }; + +export interface SpotlightCommand { + id: string; + title: string; + subtitle?: string; + icon: ComponentType | (() => ReactElement); + /** Extra search terms beyond the title (synonyms, abbreviations). */ + keywords?: string[]; + group: SpotlightGroup; + /** Optional global hotkey to display on the right of the row, e.g. `'c'`. */ + shortcut?: string; + /** + * Apple-Tahoe-style 2-letter mnemonic. Typing the key + space at the + * start of an empty input fires the command immediately. Must be exactly + * two lowercase ASCII chars to keep dispatch unambiguous. + */ + quickKey?: string; + /** Predicate to hide the command when env doesn't match. */ + when?: (env: SpotlightContextEnv) => boolean; + /** Mark commands that require an inline confirm before `perform` runs. */ + destructive?: boolean; + /** Mark commands that require login; we still render but route via auth. */ + requiresAuth?: boolean; + /** Show a "Plus" badge instead of hiding the row when not Plus. */ + plusBadge?: boolean; + /** + * Optional typed metadata. When present the modal renders a custom row + * for that entity kind (avatar, handle, hashtag glyph, see-all CTA). + */ + meta?: SpotlightCommandMeta; + /** Action to execute. May be async. */ + perform: () => + | SpotlightCommandResult + | void + | Promise; +} + +export interface SpotlightCommandResult { + keepOpen?: boolean; +} + +/** + * Apple-Tahoe browse-mode equivalent. When the modal is in a non-`All` scope, + * only the matching entity-search results are visible and the placeholder + * adapts. Backspace on an empty input pops back to `All`. + */ +export enum SpotlightScope { + All = 'all', + Actions = 'actions', + Posts = 'posts', + Squads = 'squads', + People = 'people', + Tags = 'tags', +} + +export interface ScopeMetaEntry { + label: string; + /** Used for tooltips and ARIA labels on the trigger icon buttons. */ + triggerLabel: string; + /** Used as Command.Input placeholder while the scope is active. */ + placeholder: string; + /** Single-letter index for Alt+1..N dispatch (1-based). */ + shortcutIndex: number; + /** Optional — only entity scopes hit a backend search provider. */ + searchProvider?: SearchProviderEnum; +} + +export const scopeOrder: Array> = [ + SpotlightScope.Actions, + SpotlightScope.Posts, + SpotlightScope.Squads, + SpotlightScope.People, + SpotlightScope.Tags, +]; + +export const scopeMeta: Record< + Exclude, + ScopeMetaEntry +> = { + [SpotlightScope.Actions]: { + label: 'Actions', + triggerLabel: 'Browse all actions', + placeholder: 'Browse actions and settings...', + shortcutIndex: 1, + }, + [SpotlightScope.Posts]: { + label: 'Posts', + triggerLabel: 'Search posts', + placeholder: 'Search posts...', + shortcutIndex: 2, + searchProvider: SearchProviderEnum.Posts, + }, + [SpotlightScope.Squads]: { + label: 'Squads', + triggerLabel: 'Search squads', + placeholder: 'Search squads...', + shortcutIndex: 3, + searchProvider: SearchProviderEnum.Sources, + }, + [SpotlightScope.People]: { + label: 'People', + triggerLabel: 'Search people', + placeholder: 'Search people...', + shortcutIndex: 4, + searchProvider: SearchProviderEnum.Users, + }, + [SpotlightScope.Tags]: { + label: 'Tags', + triggerLabel: 'Search tags', + placeholder: 'Search tags...', + shortcutIndex: 5, + searchProvider: SearchProviderEnum.Tags, + }, +}; + +export interface RecentCommandEntry { + commandId: string; + lastUsedAt: number; +} + +export const RECENT_STORAGE_KEY = 'daily:spotlight:recent'; +export const RECENT_MAX_ENTRIES = 8; diff --git a/packages/shared/src/components/spotlight/useQuickKeyDispatch.ts b/packages/shared/src/components/spotlight/useQuickKeyDispatch.ts new file mode 100644 index 00000000000..a2501d038ce --- /dev/null +++ b/packages/shared/src/components/spotlight/useQuickKeyDispatch.ts @@ -0,0 +1,57 @@ +import { useEffect, useRef } from 'react'; +import { type SpotlightCommand, SpotlightScope } from './types'; + +interface UseQuickKeyDispatchOptions { + /** Live input value (raw, unmodified). */ + query: string; + /** Setter that clears the input once a quick key fires. */ + setQuery: (value: string) => void; + /** All currently registered commands. */ + commands: SpotlightCommand[]; + /** Active scope. Quick keys are no-ops while a scope is active. */ + scope: SpotlightScope; + /** Fires the matched command. */ + onDispatch: (command: SpotlightCommand) => void; +} + +const QUICK_KEY_PATTERN = /^([a-z]{2}) $/; + +/** + * Apple-Tahoe-style "Quick Keys" dispatcher. Watching the input value, this + * hook fires a command immediately when: + * 1. The active scope is `All` (preserving the input for scoped search). + * 2. The query exactly matches `xx ` (two lowercase letters then a space). + * 3. A registered command's `quickKey` equals the two-letter prefix. + * + * The input is cleared and the command runs. Otherwise the user keeps typing + * normally; this is non-disruptive. + */ +export const useQuickKeyDispatch = ({ + query, + setQuery, + commands, + scope, + onDispatch, +}: UseQuickKeyDispatchOptions): void => { + const onDispatchRef = useRef(onDispatch); + onDispatchRef.current = onDispatch; + const setQueryRef = useRef(setQuery); + setQueryRef.current = setQuery; + + useEffect(() => { + if (scope !== SpotlightScope.All) { + return; + } + const match = QUICK_KEY_PATTERN.exec(query); + if (!match) { + return; + } + const key = match[1]; + const command = commands.find((cmd) => cmd.quickKey === key); + if (!command) { + return; + } + setQueryRef.current(''); + onDispatchRef.current(command); + }, [query, commands, scope]); +}; diff --git a/packages/shared/src/components/spotlight/useRecentCommands.ts b/packages/shared/src/components/spotlight/useRecentCommands.ts new file mode 100644 index 00000000000..3618e68ff64 --- /dev/null +++ b/packages/shared/src/components/spotlight/useRecentCommands.ts @@ -0,0 +1,92 @@ +import { useCallback, useEffect, useState } from 'react'; +import { storageWrapper } from '../../lib/storageWrapper'; +import { + RECENT_MAX_ENTRIES, + RECENT_STORAGE_KEY, + type RecentCommandEntry, +} from './types'; + +const safeParse = (raw: string | null): RecentCommandEntry[] => { + if (!raw) { + return []; + } + try { + const parsed = JSON.parse(raw) as unknown; + if (!Array.isArray(parsed)) { + return []; + } + return parsed + .filter( + (entry): entry is RecentCommandEntry => + !!entry && + typeof entry === 'object' && + typeof (entry as RecentCommandEntry).commandId === 'string' && + typeof (entry as RecentCommandEntry).lastUsedAt === 'number', + ) + .slice(0, RECENT_MAX_ENTRIES); + } catch { + return []; + } +}; + +export interface UseRecentCommands { + recent: RecentCommandEntry[]; + /** Refreshes from storage; useful when the palette opens (cross-tab sync). */ + refresh: () => void; + /** Push a command id to the front (last-write-wins across tabs). */ + push: (commandId: string) => void; + /** Remove a single command id (e.g. when it's gated out). */ + forget: (commandId: string) => void; +} + +/** + * SSR-safe Recents store. Reads from `localStorage` via `storageWrapper` + * (which falls back to in-memory when storage is unavailable). Last-write-wins + * across tabs — the 8-entry list does not warrant `BroadcastChannel`. + */ +export const useRecentCommands = (): UseRecentCommands => { + const [recent, setRecent] = useState([]); + + const refresh = useCallback(() => { + if (typeof window === 'undefined') { + return; + } + setRecent(safeParse(storageWrapper.getItem(RECENT_STORAGE_KEY))); + }, []); + + useEffect(() => { + refresh(); + }, [refresh]); + + const persist = useCallback((next: RecentCommandEntry[]) => { + setRecent(next); + if (typeof window === 'undefined') { + return; + } + storageWrapper.setItem(RECENT_STORAGE_KEY, JSON.stringify(next)); + }, []); + + const push = useCallback( + (commandId: string) => { + const current = safeParse(storageWrapper.getItem(RECENT_STORAGE_KEY)); + const filtered = current.filter((entry) => entry.commandId !== commandId); + const next: RecentCommandEntry[] = [ + { commandId, lastUsedAt: Date.now() }, + ...filtered, + ].slice(0, RECENT_MAX_ENTRIES); + persist(next); + }, + [persist], + ); + + const forget = useCallback( + (commandId: string) => { + const current = safeParse(storageWrapper.getItem(RECENT_STORAGE_KEY)); + const next = current.filter((entry) => entry.commandId !== commandId); + persist(next); + }, + [persist], + ); + + return { recent, refresh, push, forget }; +}; diff --git a/packages/shared/src/components/spotlight/useSpotlightCommands.ts b/packages/shared/src/components/spotlight/useSpotlightCommands.ts new file mode 100644 index 00000000000..0905f55d078 --- /dev/null +++ b/packages/shared/src/components/spotlight/useSpotlightCommands.ts @@ -0,0 +1,93 @@ +import { useCallback, useMemo } from 'react'; +import { useRouter } from 'next/router'; +import { useAuthContext } from '../../contexts/AuthContext'; +import { useSettingsContext } from '../../contexts/SettingsContext'; +import { useLazyModal } from '../../hooks/useLazyModal'; +import { usePlusSubscription } from '../../hooks/usePlusSubscription'; +import { ViewSize, useViewSize } from '../../hooks'; +import { isExtension as isExtensionConst } from '../../lib/func'; +import type { LazyModal } from '../modals/common/types'; +import { + SpotlightActionGroup, + type SpotlightAction, +} from '../../graphql/spotlight'; +import { resolveSpotlightIcon } from './iconRegistry'; +import { useSpotlight } from './SpotlightContext'; +import { dispatchSpotlightAction } from './dispatcher'; +import { + type SpotlightCommand, + type SpotlightContextEnv, + SpotlightGroup, +} from './types'; + +const API_GROUP_MAP: Record = { + [SpotlightActionGroup.Navigate]: SpotlightGroup.Navigate, + [SpotlightActionGroup.Create]: SpotlightGroup.Create, + [SpotlightActionGroup.Settings]: SpotlightGroup.Settings, + [SpotlightActionGroup.Actions]: SpotlightGroup.Actions, + [SpotlightActionGroup.Help]: SpotlightGroup.Help, + [SpotlightActionGroup.Search]: SpotlightGroup.Search, +}; + +export type UseSpotlightCommandsResult = { + commands: SpotlightCommand[]; + env: SpotlightContextEnv; + isLoading: boolean; +}; + +export const useSpotlightCommands = (): UseSpotlightCommandsResult => { + const router = useRouter(); + const { user, isLoggedIn, isAuthReady, logout } = useAuthContext(); + const settings = useSettingsContext(); + const { openModal } = useLazyModal(); + const { isPlus } = usePlusSubscription(); + const isMobile = !useViewSize(ViewSize.Laptop); + const { + actions: apiActions, + isActionsLoading: isLoading, + pushScope, + } = useSpotlight(); + + const env = useMemo( + () => ({ + isLoggedIn, + isAuthReady, + isPlus, + isExtension: isExtensionConst, + isMobile, + }), + [isLoggedIn, isAuthReady, isPlus, isMobile], + ); + + const buildApiCommand = useCallback( + (action: SpotlightAction): SpotlightCommand => ({ + id: `api.${action.id}`, + title: action.title, + subtitle: action.subtitle ?? undefined, + icon: resolveSpotlightIcon(action.icon), + keywords: action.keywords, + group: API_GROUP_MAP[action.group], + shortcut: action.shortcut ?? undefined, + quickKey: action.quickKey ?? undefined, + requiresAuth: !!action.requiresAuth, + perform: () => + dispatchSpotlightAction(action, { + router, + openModal, + settings, + user, + logout, + pushScope, + }), + }), + [router, openModal, settings, user, logout, pushScope], + ); + + const commands = useMemo(() => { + return apiActions + .map(buildApiCommand) + .filter((command) => !command.when || command.when(env)); + }, [apiActions, buildApiCommand, env]); + + return { commands, env, isLoading }; +}; diff --git a/packages/shared/src/graphql/spotlight.ts b/packages/shared/src/graphql/spotlight.ts new file mode 100644 index 00000000000..3c412056bae --- /dev/null +++ b/packages/shared/src/graphql/spotlight.ts @@ -0,0 +1,56 @@ +import { gql } from 'graphql-request'; + +export enum SpotlightActionGroup { + Navigate = 'Navigate', + Create = 'Create', + Settings = 'Settings', + Actions = 'Actions', + Help = 'Help', + Search = 'Search', +} + +export enum SpotlightActionKind { + OpenModal = 'OpenModal', + OpenUrl = 'OpenUrl', + Navigate = 'Navigate', + ToggleSetting = 'ToggleSetting', + RunClientAction = 'RunClientAction', +} + +export type SpotlightPlatform = 'webapp' | 'extension'; + +export type SpotlightAction = { + id: string; + group: SpotlightActionGroup; + title: string; + subtitle?: string | null; + icon: string; + keywords: string[]; + shortcut?: string | null; + quickKey?: string | null; + requiresAuth?: boolean | null; + requiresPlus?: boolean | null; + platforms?: SpotlightPlatform[] | null; + kind: SpotlightActionKind; + payload: Record; +}; + +export const SPOTLIGHT_ACTIONS_QUERY = gql` + query SpotlightActions { + spotlightActions { + id + group + title + subtitle + icon + keywords + shortcut + quickKey + requiresAuth + requiresPlus + platforms + kind + payload + } + } +`; diff --git a/packages/shared/src/lib/log.ts b/packages/shared/src/lib/log.ts index fbfbabd74a3..8142d3eaaba 100644 --- a/packages/shared/src/lib/log.ts +++ b/packages/shared/src/lib/log.ts @@ -472,6 +472,8 @@ export enum LogEvent { } export enum TargetType { + Spotlight = 'spotlight', + SpotlightCommand = 'spotlight command', MyFeedModal = 'my feed modal', ArticleAnonymousCTA = 'article anonymous cta', EnableNotifications = 'enable notifications', @@ -553,6 +555,7 @@ export enum TargetType { export enum TargetId { On = 'on', Off = 'off', + SpotlightOpen = 'spotlight open', SearchReferralBadge = 'search referral badge', InviteBanner = 'invite banner', InviteProfileMenu = 'invite in profile menu', diff --git a/packages/webapp/__tests__/SearchPage.tsx b/packages/webapp/__tests__/SearchPage.tsx index 40c07f75b43..47a6335776a 100644 --- a/packages/webapp/__tests__/SearchPage.tsx +++ b/packages/webapp/__tests__/SearchPage.tsx @@ -38,6 +38,6 @@ const renderComponent = (layout = getLayout): RenderResult => { it('should render the search page', async () => { renderComponent(undefined); - const text = await screen.findByTestId('search-panel'); + const text = await screen.findByText('Ready to dive in?'); expect(text).toBeInTheDocument(); }); diff --git a/packages/webapp/package.json b/packages/webapp/package.json index a53cd1af1e5..9576b70e6d0 100644 --- a/packages/webapp/package.json +++ b/packages/webapp/package.json @@ -71,6 +71,7 @@ "@types/react": "18.3.12", "@types/react-dom": "18.3.1", "@types/react-modal": "^3.16.3", + "@types/testing-library__jest-dom": "^5.14.9", "@typescript-eslint/eslint-plugin": "^7.18.0", "@typescript-eslint/parser": "^7.18.0", "autoprefixer": "^10.4.27", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 837af9ded48..5f55c5b7509 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -447,6 +447,9 @@ importers: classnames: specifier: ^2.3.1 version: 2.5.1 + cmdk: + specifier: ^1.0.0 + version: 1.1.1(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) emojibase-data: specifier: ^17.0.0 version: 17.0.0(emojibase@17.0.0) @@ -981,6 +984,9 @@ importers: '@types/react-modal': specifier: ^3.16.3 version: 3.16.3 + '@types/testing-library__jest-dom': + specifier: ^5.14.9 + version: 5.14.9 '@typescript-eslint/eslint-plugin': specifier: ^7.18.0 version: 7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.6.3))(eslint@8.57.1)(typescript@5.6.3) @@ -3190,6 +3196,19 @@ packages: '@types/react': optional: true + '@radix-ui/react-dialog@1.1.15': + resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-direction@1.1.1': resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==} peerDependencies: @@ -5340,6 +5359,12 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + cmdk@1.1.1: + resolution: {integrity: sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==} + peerDependencies: + react: ^18 || ^19 || ^19.0.0-rc + react-dom: ^18 || ^19 || ^19.0.0-rc + co@4.6.0: resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} @@ -12439,6 +12464,28 @@ snapshots: optionalDependencies: '@types/react': 18.3.12 + '@radix-ui/react-dialog@1.1.15(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.2.3(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.12)(react@18.3.1) + aria-hidden: 1.2.6 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-remove-scroll: 2.7.1(@types/react@18.3.12)(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.12 + '@types/react-dom': 18.3.1 + '@radix-ui/react-direction@1.1.1(@types/react@18.3.12)(react@18.3.1)': dependencies: react: 18.3.1 @@ -14841,6 +14888,18 @@ snapshots: clsx@2.1.1: {} + cmdk@1.1.1(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + co@4.6.0: {} coa@2.0.2: