Files
bsky-social-app/src/screens/Search/components/AdvancedSearchDialog/index.tsx
T

455 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {useMemo, useRef, useState} from 'react'
import {type ScrollView, View} from 'react-native'
import {Plural, Trans, useLingui} from '@lingui/react/macro'
import {
countActiveFilters,
FILTER_PARAM_KEYS,
hasActiveFilters,
type SearchFilters,
} from '#/screens/Search/searchParams'
import {atoms as a, native, useBreakpoints, useTheme} from '#/alf'
import {Admonition} from '#/components/Admonition'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import * as TextField from '#/components/forms/TextField'
import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus'
import {SettingsSliderVertical_Stroke2_Corner0_Rounded as SettingsSliderIcon} from '#/components/icons/SettingsSlider'
import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
import {IS_WEB} from '#/env'
import {SearchLanguageDropdown} from '../SearchLanguageDropdown'
import {ClearableDateField, DEFAULT_DATE} from './ClearableDateField'
import {ClearableInput} from './ClearableInput'
import {FilterBlock} from './FilterBlock'
import {FromDropdown} from './FromDropdown'
import {MediaDropdown} from './MediaDropdown'
import {RepliesDropdown} from './RepliesDropdown'
import {
type AdvancedFilter,
type FromFilter,
makeFilter,
type MediaFilter,
parseAdvancedSearch,
type RepliesFilter,
serializeAdvancedSearch,
} from './utils'
const MAX_FILTERS = 20
export function AdvancedSearchDialog({
disabled,
q,
filters,
onSubmit,
}: {
disabled: boolean
q: string
filters: SearchFilters
onSubmit: (q: string, filters: SearchFilters) => void
}) {
const ax = useAnalytics()
const {t: l} = useLingui()
const control = Dialog.useDialogControl()
const filtersActive = hasActiveFilters(filters)
const stateKey = useMemo(
() =>
JSON.stringify([q, ...FILTER_PARAM_KEYS.map(key => filters[key] ?? '')]),
[q, filters],
)
return (
<>
<View style={[a.relative]}>
<Button
disabled={disabled}
label={l`Open advanced search options`}
size="small"
color={filtersActive ? 'primary_subtle' : 'secondary'}
style={native([a.py_sm, a.px_sm])}
onPress={() => {
ax.metric('search:advanced:press', {
filterCount: countActiveFilters(filters),
})
control.open()
}}>
<ButtonIcon icon={SettingsSliderIcon} />
<ButtonText>
<Trans context="search">Filters</Trans>
</ButtonText>
</Button>
</View>
<Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}>
<Dialog.Handle />
<DialogInner
key={stateKey}
control={control}
q={q}
filters={filters}
onSubmit={onSubmit}
/>
</Dialog.Outer>
</>
)
}
function DialogInner({
control,
q,
filters: filterParams,
onSubmit,
}: {
control: Dialog.DialogControlProps
q: string
filters: SearchFilters
onSubmit: (q: string, filters: SearchFilters) => void
}) {
const ax = useAnalytics()
const t = useTheme()
const {t: l} = useLingui()
const {gtTablet} = useBreakpoints()
// Two-column layout for the word fields, web-only at the widest breakpoint.
const twoColumn = IS_WEB && gtTablet
const parsed = useMemo(
() => parseAdvancedSearch(q, filterParams),
[q, filterParams],
)
const [query, setQuery] = useState(parsed.query)
const [exactPhrase, setExactPhrase] = useState(parsed.exactPhrase)
const [negatedWords, setNegatedWords] = useState(parsed.negatedWords)
const [language, setLanguage] = useState(parsed.language)
const [media, setMedia] = useState<MediaFilter>(parsed.media)
const [replies, setReplies] = useState<RepliesFilter>(parsed.replies)
const [following, setFollowing] = useState<FromFilter>(parsed.following)
/*
* The date picker requires a valid date, so these always hold one. The
* accompanying `active` flags track whether the date is actually part of the
* query, so that a date equal to today (the default) can still be applied.
*/
const [dateSince, setDateSince] = useState(parsed.since || DEFAULT_DATE)
const [dateSinceActive, setDateSinceActive] = useState(!!parsed.since)
const [dateUntil, setDateUntil] = useState(parsed.until || DEFAULT_DATE)
const [dateUntilActive, setDateUntilActive] = useState(!!parsed.until)
const [filters, setFilters] = useState<AdvancedFilter[]>(parsed.filters)
const scrollRef = useRef<ScrollView>(null)
const filtersSectionRef = useRef<View>(null)
function addFilter() {
if (filters.length >= MAX_FILTERS) return
/*
* New blocks append to the end so the newest sits directly above the
* "Add filter" button, which renders below the list.
*/
setFilters(prev => [...prev, makeFilter('authors')])
ax.metric('search:addFilter:press', {
filterCount: filters.length + 1,
})
/*
* Wait for the new block to render, then scroll the bottom of the dialog
* (the new block plus the button beneath it) into view.
*/
requestAnimationFrame(() => {
if (IS_WEB) {
const node = filtersSectionRef.current as unknown as HTMLElement | null
node?.scrollIntoView?.({behavior: 'smooth', block: 'end'})
} else {
scrollRef.current?.scrollToEnd({animated: true})
}
})
}
function updateFilter(id: string, patch: Partial<AdvancedFilter>) {
setFilters(prev =>
prev.map(filter => (filter.id === id ? {...filter, ...patch} : filter)),
)
}
function removeFilter(id: string) {
setFilters(prev => prev.filter(filter => filter.id !== id))
}
function handlePressSearch() {
const {q: nextQ, filters: nextFilters} = serializeAdvancedSearch({
query,
exactPhrase,
negatedWords,
language,
replies,
media,
following,
dateSince,
dateSinceActive,
dateUntil,
dateUntilActive,
filters,
})
/*
* Run the submit (navigation + state updates) inside the close callback so
* it doesn't race the sheet's close animation on native.
*/
control.close(() => onSubmit(nextQ, nextFilters))
}
function cancelButton() {
return (
<Button
label={l`Cancel`}
onPress={() => control.close()}
size="small"
color="secondary"
variant="ghost"
style={[a.rounded_full]}>
<ButtonText>
<Trans>Cancel</Trans>
</ButtonText>
</Button>
)
}
function searchButton() {
return (
<Button
label={l`Search`}
onPress={handlePressSearch}
size="small"
color="primary"
style={[a.rounded_full]}>
<ButtonText>
<Trans>Search</Trans>
</ButtonText>
</Button>
)
}
return (
<Dialog.ScrollableInner
ref={scrollRef}
label={l`Dialog: Set search filters`}
contentContainerStyle={[a.px_0, a.pt_0]}
header={
<Dialog.Header renderLeft={cancelButton} renderRight={searchButton}>
<Dialog.HeaderText>
<Trans context="search">Filters</Trans>
</Dialog.HeaderText>
</Dialog.Header>
}>
<View style={[a.mt_xl, a.px_xl, a.gap_xl]}>
<View style={[a.flex_1]}>
<TextField.LabelText>
<Trans>All of these words</Trans>
</TextField.LabelText>
<ClearableInput
label={l`Search query`}
defaultValue={query}
placeholder={l({
message: 'cats dogs',
comment:
'Advanced search: Example of an “all of these words” search. Paired with “cows pigs”.',
})}
onChangeText={setQuery}
onSubmitEditing={handlePressSearch}
/>
</View>
<View style={[twoColumn ? a.flex_row : a.flex_col, a.gap_xl]}>
<View style={[a.flex_1]}>
<TextField.LabelText>
<Trans>None of these words</Trans>
</TextField.LabelText>
<ClearableInput
label={l`None of these words`}
defaultValue={negatedWords}
placeholder={l({
message: 'cows pigs',
comment:
'Advanced search: Example of a “none of these words” search. Paired with “cats dogs”.',
})}
onChangeText={setNegatedWords}
onSubmitEditing={handlePressSearch}
/>
</View>
<View style={[a.flex_1]}>
<TextField.LabelText>
<Trans>This exact phrase</Trans>
</TextField.LabelText>
<ClearableInput
label={l`This exact phrase`}
defaultValue={exactPhrase}
placeholder={l({
message: 'whats up',
comment: 'Advanced search: Example of an “exact phrase” search',
})}
onChangeText={setExactPhrase}
onSubmitEditing={handlePressSearch}
/>
</View>
</View>
<View>
<View style={[a.flex_row, a.gap_lg]}>
<View style={[a.flex_1]}>
<TextField.LabelText>
<Trans>Since</Trans>
</TextField.LabelText>
<ClearableDateField
label={l`Since`}
value={dateSince}
active={dateSinceActive}
accessibilityHint={l({
message: 'Include posts made since this date',
comment: 'Advanced search filter',
})}
// Can't choose a Since later than an active Until.
maximumDate={dateUntilActive ? dateUntil : DEFAULT_DATE}
onConfirm={(value: string) => {
setDateSince(value)
setDateSinceActive(true)
}}
onClear={() => {
setDateSinceActive(false)
setDateSince(DEFAULT_DATE)
}}
/>
</View>
<View style={[a.flex_1]}>
<TextField.LabelText>
<Trans>Until</Trans>
</TextField.LabelText>
<ClearableDateField
label={l`Until`}
value={dateUntil}
active={dateUntilActive}
accessibilityHint={l({
message: 'Include posts made until this date',
comment: 'Advanced search filter',
})}
// Can't choose an Until earlier than an active Since.
minimumDate={dateSinceActive ? dateSince : undefined}
onConfirm={(value: string) => {
setDateUntil(value)
setDateUntilActive(true)
}}
onClear={() => {
setDateUntilActive(false)
setDateUntil(DEFAULT_DATE)
}}
/>
</View>
</View>
</View>
<View style={[twoColumn ? a.flex_row : a.flex_col, a.gap_lg]}>
<View style={[a.flex_1]}>
<Text
style={[
a.text_sm,
a.font_medium,
t.atoms.text_contrast_medium,
a.mb_sm,
]}>
<Trans>Language</Trans>
</Text>
<View style={[a.flex_row]}>
<SearchLanguageDropdown
showIcon={false}
value={language}
onChange={setLanguage}
/>
</View>
</View>
<View style={[a.flex_1]}>
<Text
style={[
a.text_sm,
a.font_medium,
t.atoms.text_contrast_medium,
a.mb_sm,
]}>
<Trans>Media</Trans>
</Text>
<View style={[a.flex_row]}>
<MediaDropdown value={media} onChange={setMedia} />
</View>
</View>
</View>
<View style={[twoColumn ? a.flex_row : a.flex_col, a.gap_lg]}>
<View style={[a.flex_1]}>
<Text
style={[
a.text_sm,
a.font_medium,
t.atoms.text_contrast_medium,
a.mb_sm,
]}>
<Trans comment="Include search results with or without replies">
Post type
</Trans>
</Text>
<View style={[a.flex_row]}>
<RepliesDropdown value={replies} onChange={setReplies} />
</View>
</View>
<View style={[a.flex_1]}>
<Text
style={[
a.text_sm,
a.font_medium,
t.atoms.text_contrast_medium,
a.mb_sm,
]}>
<Trans comment="Filter search results by a specific post author">
Author
</Trans>
</Text>
<View style={[a.flex_row]}>
<FromDropdown value={following} onChange={setFollowing} />
</View>
</View>
</View>
<View ref={filtersSectionRef} style={[a.gap_md]}>
{filters.map(filter => (
<FilterBlock
key={filter.id}
filter={filter}
onChange={patch => updateFilter(filter.id, patch)}
onSubmitEditing={handlePressSearch}
onRemove={() => removeFilter(filter.id)}
/>
))}
{filters.length >= MAX_FILTERS && (
<Admonition type="info">
<Trans>
Youve reached the maximum of{' '}
<Plural value={MAX_FILTERS} one="# filter" other="# filters" />.
Add more values to an existing filter instead of creating new
ones.
</Trans>
</Admonition>
)}
<Button
label={l`Add another search filter`}
size="small"
color="secondary"
disabled={filters.length >= MAX_FILTERS}
style={[a.mt_sm]}
onPress={addFilter}>
<ButtonIcon icon={PlusIcon} />
<ButtonText>
<Trans>Add filter</Trans>
</ButtonText>
</Button>
</View>
</View>
</Dialog.ScrollableInner>
)
}