refactor: tend to the profile search page
44 unmodified lines45464748495051525354555657585960616278 unmodified lines1411421431441451461471481491501511521531541551501561571581591602 unmodified lines1631641651661671681691701711721731741751761771781791801818 unmodified lines19019119219318519419519618919719819920020116 unmodified lines2182192202212222232242252261 unmodified line22822923023123223322323423523623723823924017 unmodified lines25825926026124926226326426520 unmodified lines28628728828927827928028128229029129229329429529629729829930053 unmodified lines354355356357358359360361362321 unmodified linesExpand all44 unmodified linesimport * as SearchField from '#/components/forms/SearchField';
import { m } from '#/paraglide/messages';import { getRouter } from '#/router';import type { SearchHistoryEntry } from '#/storage';
import { CalendarBody } from './CalendarBody';import { buildResult, CALENDAR_DAY_COUNT, type InteractiveItem, interactiveItems } from './model';import { classifyActiveToken, findActiveToken, getDateConstraints, getOperatorSuggestions, type OperatorName, parseStartDate, splitFilters,} from './query-syntax';import { Row } from './Row';78 unmodified lines
return [start, start + tokens[tokenIndex]!.value.length];};
const NO_FIXED_FILTERS: readonly OperatorName[] = [];const NO_HISTORY: readonly SearchHistoryEntry[] = [];
type SearchAutocompleteProps = { fixedFilters?: readonly OperatorName[]; onNavigate: (path: string) => void; onNavigateToProfile: (profile: AnyProfileView) => void; onSubmit: (query: string) => void;};
type SearchAutocompleteFieldProps = SearchAutocompleteProps & { /** mount the live field immediately rather than the cheap idle placeholder, without grabbing focus on mount. */ autoFocus?: boolean; eager?: boolean; /** seed text for the field; re-seeds the field whenever it changes (e.g. navigating between searches). */ initialQuery?: string; /** placeholder shown while the field is empty. */2 unmodified lines
/** * a search entry point that lazily mounts the autocomplete search field on first engagement unless eager. * * @param autoFocus whether to focus an eager field on mount * @param eager mount the live field immediately instead of the lazy placeholder * @param fixedFilters operators supplied outside the editable query * @param initialQuery text to seed the live field with * @param onNavigate callback to navigate to an in-app route path * @param onNavigateToProfile callback to open a profile chosen from the typeahead * @param onSubmit callback to run a search for the query * @param placeholder placeholder shown while the field is empty */export function SearchAutocomplete({ autoFocus, eager, initialQuery, placeholder, ...props8 unmodified lines }); }, []);
if (active) { // lazy fields receive focus on activation; eager fields do not. return ( <ActiveSearchAutocomplete {...props} autoFocus={!eager} autoFocus={autoFocus || !eager} initialQuery={initialQuery} placeholder={placeholder} /> );16 unmodified lines * actor list, and a calendar grid. */function ActiveSearchAutocomplete({ autoFocus, fixedFilters = NO_FIXED_FILTERS, initialQuery = '', onNavigate, onNavigateToProfile, onSubmit,1 unmodified line}: SearchAutocompleteProps & { autoFocus: boolean; initialQuery?: string; placeholder?: string }) { const { currentAccount } = useSession(); const { data: meProfile } = useProfileQuery({ did: currentAccount?.did });
const scoped = fixedFilters.length > 0;
const history = useSearchHistory(); const storedHistory = useSearchHistory(); // fixed filters invalidate global history. const history = scoped ? NO_HISTORY : storedHistory; const recentProfileDids = mapDefined(history, (entry) => entry.kind === 'profile' ? entry.did : undefined, ); const { data: recentProfileData, isPending: recentProfilesPending } = useProfilesQuery({17 unmodified lines inputRef.current?.focus(); }); }, []);
// focus lazy fields before paint; eager fields start closed and unfocused. useLayoutEffect(() => { if (autoFocus) { inputRef.current?.focus(); }20 unmodified lines // keep parsed query data stable for the date-picker callbacks. const tokens = useMemo(() => tokenize(query), [query]); const active = useMemo(() => findActiveToken(tokens, caret), [tokens, caret]); const mode = useMemo(() => classifyActiveToken(active), [active]); const operatorSuggestions = getOperatorSuggestions(tokens, active); const fromActive = tokens.some( (token, index) => index !== active?.tokenIndex && token.type === 'word' && token.value.startsWith('from:'), ); const operatorSuggestions = getOperatorSuggestions(tokens, active, fixedFilters); const fromActive = fixedFilters.includes('from') || tokens.some( (token, index) => index !== active?.tokenIndex && token.type === 'word' && token.value.startsWith('from:'), );
// the selectable date range for the active `since`/`until` picker (day-granular bounds). const dateConstraints = useMemo( () => (mode.kind === 'date' ? getDateConstraints(tokens, mode.op, today) : null),53 unmodified lines query, recentProfiles, // reserve a row for unresolved recent profiles while they load. recentProfilesPending, scoped, today, visibleMonth, }); const items = interactiveItems(result);321 unmodified linesExpand all103 unmodified linesExpand all10410510610710810810911011111297 unmodified lines2102112122132142152162172186 unmodified lines22522622722822923023123223323423323523623723823924024124224324424524651 unmodified lines2982993003013023033043053063073084 unmodified lines31331431531631231331431531731831932032132232332432532632732813 unmodified lines103 unmodified linesExpand all * * @param pending whether the profile hydration is currently loading */const buildRecentRows = ( history: SearchHistoryEntry[], history: readonly SearchHistoryEntry[], recentProfiles: Map<string, AnyProfileView>, pending: boolean,): ListRow[] => { const rows: ListRow[] = [];97 unmodified lines * @param profiles actor/profile typeahead matches * @param query raw search query * @param recentProfiles resolved profile views for the history's profile entries, keyed by DID * @param recentProfilesPending whether recent profiles' hydration is still in flight * @param scoped whether the search has fixed filters * @param today current day, highlighted in the calendar * @param visibleMonth month the calendar is showing * @returns tagged result for the renderer */6 unmodified lines profiles, query, recentProfiles, recentProfilesPending, scoped, today, visibleMonth,}: { constraints: DateConstraints; fromActive: boolean; history: SearchHistoryEntry[]; history: readonly SearchHistoryEntry[]; mode: SuggestionMode; operators: SearchOperator[]; profiles: AnyProfileView[]; query: string; recentProfiles: Map<string, AnyProfileView>; recentProfilesPending: boolean; scoped: boolean; today: Date; visibleMonth: Date;}): AutocompleteResult => { switch (mode.kind) {51 unmodified lines ...recent, ); } } // the hero is specific to global search. if ( !scoped && !rows.some( (row) => row.kind === 'profile' || row.kind === 'recentProfile' ||4 unmodified lines ) { rows.push({ kind: 'hero', key: 'hero' }); } if (operators.length > 0) { rows.push( { kind: 'divider', key: 'divider' }, { kind: 'sectionLabel', key: 'options-label', label: m['components.web.search.filter.label']() }, ); if (rows.length > 0) { rows.push({ kind: 'divider', key: 'divider' }); } rows.push({ kind: 'sectionLabel', key: 'options-label', label: m['components.web.search.filter.label'](), }); for (const operator of operators) { rows.push({ kind: 'operator', key: `operator-${operator.name}`, operator }); } }13 unmodified lines137 unmodified linesExpand all13813914014114214314414514614714814915015115215315493 unmodified lines2482492502512522532542552562572582592602612623 unmodified lines26626726826927027127227327427527627780 unmodified lines137 unmodified linesExpand all { kind: 'enum', name: 'has', options: ['media', 'video'], placeholder: 'media' }, { kind: 'enum', name: 'replies', options: ['none', 'only'], placeholder: 'none' },];
/** * checks whether a string is a recognized operator name. * * @param name value to check * @returns whether the value is an operator name */export const isOperatorName = (name: string): name is OperatorName => SEARCH_OPERATORS.some((operator) => operator.name === name);
const MAYBE_HANDLE_RE = /^@?[a-zA-Z0-9-.]*$/;const MAYBE_DATE_RE = /^[\d\-+.:Z]*$/;
/** splits a word into its operator name and value, with an undefined value when there is no colon. */93 unmodified lines * returns unused operators matching the token under the caret. * * @param tokens tokenized query * @param active token under the caret * @param fixedFilters operators excluded from suggestions * @returns operators to list under "search options" */export const getOperatorSuggestions = ( tokens: Token[], active: ActiveToken | undefined, fixedFilters: readonly OperatorName[],): SearchOperator[] => { const token = active?.token; if (token?.type === 'quoted') { return [];3 unmodified lines // inspect tokens directly because the filter map keeps only the last value. const followingSet = tokens.some((t) => t.type === 'word' && t.value === 'from:following');
return SEARCH_OPERATORS.filter(({ multiple, name }) => { if (fixedFilters.includes(name)) { return false; }
if (name === 'from' && followingSet) { return false; }
80 unmodified lines32 unmodified lines3334353637383940414 unmodified lines32 unmodified lines
return ( <Layout.Screen> <SearchHeader autoFocus fixedParams={{ from: profile?.handle ?? actor }} initialQuery="" navButton={<Layout.Header.BackButton />} placeholder={placeholder}4 unmodified lines1234556789101112131415162 unmodified lines1920212222232324252627282 unmodified lines31323334353637383940414243444546474817 unmodified lines6667686970717273747576777879808182838485866 unmodified linesimport type { ReactNode, Ref } from 'react';
import type { AnyProfileView } from '@atcute/bluesky';
import { definite } from '@mary/array-fns';import { definite, mapDefined } from '@mary/array-fns';
import { profileTarget } from '#/lib/routes/targets';
import type { SearchTabId } from '#/screens/Search/SearchResults';import { makeSearchQuery, type Params } from '#/screens/Search/utils';
import { isOperatorName } from '#/components/SearchAutocomplete/query-syntax';import { SearchAutocomplete } from '#/components/SearchAutocomplete/SearchAutocomplete';import * as Layout from '#/components/web/Layout';import { useNavigateToPath } from '#/components/web/Link';
2 unmodified lines/** * the search chrome shared by the Explore, Search, and ProfileSearch screens: a sticky header wrapping the * autocomplete input. submitting always hands off to the Search results screen, prefixing any fixed params. * * @param fixedParams query filters prefixed to every submitted search (e.g. the profile-search `from:` * filter) * @param autoFocus focus the search field on mount * @param fixedParams query filters prefixed to submissions and excluded from autocomplete * @param headerRef forwarded to the header element so callers can measure its height * @param initialQuery query to seed the input with; a trailing space is appended so the caret opens on a * fresh token * @param navButton the leading navigation button (menu on Explore, back on the results/profile screens)2 unmodified lines * @param tab results tab to land on when the query is submitted; carries the caller's active or pending tab * over to the Search screen */export function SearchHeader({ autoFocus, fixedParams, headerRef, initialQuery, navButton, noBottomBorder, placeholder, tab,}: { autoFocus?: boolean; fixedParams?: Params; headerRef?: Ref<HTMLDivElement>; initialQuery: string; navButton: ReactNode;17 unmodified lines const navigateToProfile = (profile: AnyProfileView) => { router.navigate({ to: profileTarget(profile.did) }); };
const fixedFilters = mapDefined(Object.entries(fixedParams ?? {}), ([name, value]) => value && isOperatorName(name) ? name : undefined, );
return ( <Layout.Header.Outer noBottomBorder={noBottomBorder} ref={headerRef}> {navButton}
<Layout.Header.Content> <SearchAutocomplete autoFocus={autoFocus} eager fixedFilters={fixedFilters} initialQuery={initialQuery ? initialQuery + ' ' : initialQuery} placeholder={placeholder} onNavigate={(path) => navigateToPath(path, 'push')} onNavigateToProfile={navigateToProfile}6 unmodified lines