Add advanced search UI (#10992)
This commit is contained in:
+153
@@ -0,0 +1,153 @@
|
||||
import {useState} from 'react'
|
||||
import {Pressable, View} from 'react-native'
|
||||
import {useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {type AutocompleteItem, useAutocomplete} from '#/components/Autocomplete'
|
||||
import {Button, ButtonIcon} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import * as TextField from '#/components/forms/TextField'
|
||||
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
|
||||
import * as ProfileCard from '#/components/ProfileCard'
|
||||
import {
|
||||
appendSelection,
|
||||
type AutocompleteInputProps,
|
||||
lastTokenOf,
|
||||
} from './shared'
|
||||
|
||||
/**
|
||||
* Native: Sift's popover positioning doesn't anchor, so the result list is
|
||||
* drawn as an absolutely-positioned overlay directly above the input. Anchoring
|
||||
* above (rather than below) keeps it clear of the on-screen keyboard, and
|
||||
* overlaying (rather than rendering inline) means it doesn't push the focused
|
||||
* input down under the keyboard. See index.tsx for the web (floating) variant.
|
||||
* The typeahead matches the last space-delimited token; selecting a result
|
||||
* completes that token and leaves a trailing space so the next value can be
|
||||
* typed.
|
||||
*/
|
||||
export function AutocompleteInput({
|
||||
label,
|
||||
value,
|
||||
placeholder,
|
||||
onChangeText,
|
||||
onSubmitEditing,
|
||||
}: AutocompleteInputProps) {
|
||||
const t = useTheme()
|
||||
const {t: l} = useLingui()
|
||||
const [focused, setFocused] = useState(false)
|
||||
|
||||
const lastToken = lastTokenOf(value)
|
||||
const {items} = useAutocomplete({type: 'profile', query: lastToken})
|
||||
|
||||
const showDropdown = focused && lastToken.length > 0 && items.length > 0
|
||||
|
||||
function selectItem(item: AutocompleteItem) {
|
||||
const next = appendSelection(value, lastToken, item)
|
||||
if (next !== null) onChangeText(next)
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[a.relative]}>
|
||||
<TextField.Root>
|
||||
<Dialog.Input
|
||||
label={label}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
keyboardAppearance={t.scheme}
|
||||
autoCorrect={false}
|
||||
autoComplete="off"
|
||||
autoCapitalize="none"
|
||||
style={[a.pr_2xl]}
|
||||
onChangeText={onChangeText}
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={() => setFocused(false)}
|
||||
onSubmitEditing={onSubmitEditing}
|
||||
/>
|
||||
</TextField.Root>
|
||||
|
||||
{value.length > 0 && (
|
||||
<View
|
||||
style={[a.absolute, a.justify_center, {top: 0, bottom: 0, right: 8}]}>
|
||||
<Button
|
||||
label={l`Clear`}
|
||||
onPress={() => onChangeText('')}
|
||||
size="tiny"
|
||||
color="secondary"
|
||||
shape="round">
|
||||
<ButtonIcon icon={XIcon} />
|
||||
</Button>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{showDropdown && <OverlayList items={items} onSelect={selectItem} />}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function OverlayList({
|
||||
items,
|
||||
onSelect,
|
||||
}: {
|
||||
items: AutocompleteItem[]
|
||||
onSelect: (item: AutocompleteItem) => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const moderationOpts = useModerationOpts()
|
||||
|
||||
if (!moderationOpts) return null
|
||||
|
||||
/*
|
||||
* The list sits above the input, so reverse it to put the most relevant
|
||||
* result (first) at the bottom, nearest the text the user is typing.
|
||||
*/
|
||||
const ordered = [...items].reverse()
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
/*
|
||||
* Drawn as an overlay anchored just above the input so it doesn't push
|
||||
* the input down under the keyboard. z_10 keeps it above sibling rows.
|
||||
*/
|
||||
a.absolute,
|
||||
a.z_10,
|
||||
a.mb_xs,
|
||||
{left: 0, right: 0, bottom: '100%'},
|
||||
a.border,
|
||||
a.rounded_sm,
|
||||
a.overflow_hidden,
|
||||
t.atoms.border_contrast_low,
|
||||
t.atoms.bg,
|
||||
]}>
|
||||
{ordered.map((item, index) => {
|
||||
if (item.type !== 'profile') return null
|
||||
return (
|
||||
<Pressable
|
||||
key={item.key}
|
||||
accessibilityRole="button"
|
||||
onPress={() => onSelect(item)}
|
||||
style={({pressed}) => [
|
||||
a.py_sm,
|
||||
a.px_md,
|
||||
index !== 0 && a.border_t,
|
||||
t.atoms.border_contrast_low,
|
||||
pressed && t.atoms.bg_contrast_25,
|
||||
]}>
|
||||
<ProfileCard.Header>
|
||||
<ProfileCard.Avatar
|
||||
disabledPreview
|
||||
profile={item.profile}
|
||||
moderationOpts={moderationOpts}
|
||||
/>
|
||||
<ProfileCard.NameAndHandle
|
||||
profile={item.profile}
|
||||
moderationOpts={moderationOpts}
|
||||
/>
|
||||
</ProfileCard.Header>
|
||||
</Pressable>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import {useEffect, useState} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {useSift} from '@bsky.app/sift'
|
||||
import {useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {
|
||||
Autocomplete,
|
||||
AutocompleteItemProfile,
|
||||
useAutocomplete,
|
||||
} from '#/components/Autocomplete'
|
||||
import {type AutocompleteItemProps} from '#/components/Autocomplete/types'
|
||||
import {Button, ButtonIcon} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import * as TextField from '#/components/forms/TextField'
|
||||
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
|
||||
import {
|
||||
appendSelection,
|
||||
type AutocompleteInputProps,
|
||||
lastTokenOf,
|
||||
} from './shared'
|
||||
|
||||
function renderItem(props: AutocompleteItemProps) {
|
||||
return <AutocompleteItemProfile {...props} />
|
||||
}
|
||||
|
||||
/**
|
||||
* Web: the result list floats above content via the shared Autocomplete/Sift
|
||||
* primitives (the same stack the composer/DM mention typeahead uses). See
|
||||
* index.native.tsx for the inline variant. The typeahead matches the last
|
||||
* space-delimited token; selecting a result completes that token and leaves a
|
||||
* trailing space so the next value can be typed.
|
||||
*/
|
||||
export function AutocompleteInput({
|
||||
label,
|
||||
value,
|
||||
placeholder,
|
||||
onChangeText,
|
||||
onSubmitEditing,
|
||||
}: AutocompleteInputProps) {
|
||||
const t = useTheme()
|
||||
const {t: l} = useLingui()
|
||||
const [focused, setFocused] = useState(false)
|
||||
/*
|
||||
* The popover is portaled and doesn't follow the page as it scrolls, so hide
|
||||
* it on scroll. Reset when the user edits or refocuses the input.
|
||||
*/
|
||||
const [dismissedByScroll, setDismissedByScroll] = useState(false)
|
||||
|
||||
const sift = useSift({
|
||||
offset: a.p_xs.padding,
|
||||
placement: 'bottom-start',
|
||||
dynamicWidth: true,
|
||||
})
|
||||
|
||||
const lastToken = lastTokenOf(value)
|
||||
const {items} = useAutocomplete({type: 'profile', query: lastToken})
|
||||
|
||||
const showDropdown =
|
||||
focused && !dismissedByScroll && lastToken.length > 0 && items.length > 0
|
||||
|
||||
useEffect(() => {
|
||||
if (!showDropdown) return
|
||||
const onScroll = () => setDismissedByScroll(true)
|
||||
/*
|
||||
* Capture phase so scrolls inside the dialog's scroll container (not just
|
||||
* the window) trigger dismissal.
|
||||
*/
|
||||
window.addEventListener('scroll', onScroll, true)
|
||||
return () => window.removeEventListener('scroll', onScroll, true)
|
||||
}, [showDropdown])
|
||||
|
||||
/*
|
||||
* The anchor ref is applied via inputRef (lands on the inner TextInput); the
|
||||
* remaining combobox a11y props are spread onto the input.
|
||||
*/
|
||||
const {ref: anchorRef, ...comboboxProps} = sift.targetProps
|
||||
|
||||
return (
|
||||
<View style={[a.relative]}>
|
||||
<TextField.Root>
|
||||
<Dialog.Input
|
||||
{...comboboxProps}
|
||||
inputRef={anchorRef}
|
||||
label={label}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
keyboardAppearance={t.scheme}
|
||||
autoCorrect={false}
|
||||
autoComplete="off"
|
||||
autoCapitalize="none"
|
||||
style={[a.pr_2xl]}
|
||||
onChangeText={text => {
|
||||
setDismissedByScroll(false)
|
||||
onChangeText(text)
|
||||
}}
|
||||
onFocus={() => {
|
||||
setDismissedByScroll(false)
|
||||
setFocused(true)
|
||||
}}
|
||||
onBlur={() => setFocused(false)}
|
||||
onSubmitEditing={onSubmitEditing}
|
||||
/>
|
||||
</TextField.Root>
|
||||
|
||||
{value.length > 0 && (
|
||||
<View
|
||||
style={[a.absolute, a.justify_center, {top: 0, bottom: 0, right: 8}]}>
|
||||
<Button
|
||||
label={l`Clear`}
|
||||
onPress={() => onChangeText('')}
|
||||
size="tiny"
|
||||
color="secondary"
|
||||
shape="round">
|
||||
<ButtonIcon icon={XIcon} />
|
||||
</Button>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{showDropdown && (
|
||||
<Autocomplete
|
||||
sift={sift}
|
||||
data={items}
|
||||
render={renderItem}
|
||||
onSelect={item => {
|
||||
const next = appendSelection(value, lastToken, item)
|
||||
if (next !== null) onChangeText(next)
|
||||
}}
|
||||
onDismiss={() => {}}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import {type AutocompleteItem} from '#/components/Autocomplete'
|
||||
|
||||
export type AutocompleteInputProps = {
|
||||
label: string
|
||||
value: string
|
||||
placeholder?: string
|
||||
onChangeText: (text: string) => void
|
||||
onSubmitEditing?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* The typeahead matches the last space-delimited token, so earlier completed
|
||||
* values are left alone while the user types the next one.
|
||||
*/
|
||||
export function lastTokenOf(value: string): string {
|
||||
return value.split(/\s+/u).pop() ?? ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the in-progress last token with the chosen handle, keeping earlier
|
||||
* values, and leaves a trailing space to start the next one. The trailing space
|
||||
* empties the last token, which dismisses the list.
|
||||
*/
|
||||
export function appendSelection(
|
||||
value: string,
|
||||
lastToken: string,
|
||||
item: AutocompleteItem,
|
||||
): string | null {
|
||||
if (item.type !== 'profile') return null
|
||||
const prefix = value.slice(0, value.length - lastToken.length)
|
||||
return `${prefix}${item.profile.handle} `
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import {View} from 'react-native'
|
||||
import {useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {atoms as a} from '#/alf'
|
||||
import {Button, ButtonIcon} from '#/components/Button'
|
||||
import {DateField} from '#/components/forms/DateField'
|
||||
import {toSimpleDateString} from '#/components/forms/DateField/utils'
|
||||
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
|
||||
|
||||
/** The date picker requires a valid date, so default to today. */
|
||||
export const DEFAULT_DATE = toSimpleDateString(new Date())
|
||||
|
||||
export function ClearableDateField({
|
||||
label,
|
||||
value,
|
||||
active,
|
||||
accessibilityHint,
|
||||
maximumDate = DEFAULT_DATE,
|
||||
minimumDate,
|
||||
onConfirm,
|
||||
onClear,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
active: boolean
|
||||
accessibilityHint?: string
|
||||
maximumDate?: string
|
||||
minimumDate?: string
|
||||
onConfirm: (value: string) => void
|
||||
onClear: () => void
|
||||
}) {
|
||||
const {t: l} = useLingui()
|
||||
|
||||
return (
|
||||
<View style={[a.w_full, a.relative]}>
|
||||
<DateField
|
||||
label={label}
|
||||
/*
|
||||
* An empty value renders the placeholder and leaves the field inactive
|
||||
* until the user confirms a date.
|
||||
*/
|
||||
value={active ? value : ''}
|
||||
placeholder={l({
|
||||
message: 'Any time',
|
||||
comment: 'Placeholder text for a date picker',
|
||||
})}
|
||||
accessibilityHint={accessibilityHint}
|
||||
maximumDate={maximumDate}
|
||||
minimumDate={minimumDate}
|
||||
/*
|
||||
* onChangeDate is required, but activation is driven by onConfirm so
|
||||
* that iOS scroll ticks don't mark the field active.
|
||||
*/
|
||||
onChangeDate={() => {}}
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
|
||||
{active && (
|
||||
/*
|
||||
* Nudged left of the browser's native date-picker icon, which (in
|
||||
* Firefox especially) can't be hidden via CSS.
|
||||
*/
|
||||
<View
|
||||
style={[
|
||||
a.absolute,
|
||||
a.justify_center,
|
||||
{top: 0, bottom: 0, right: 16},
|
||||
]}>
|
||||
<Button
|
||||
label={l`Clear date`}
|
||||
onPress={onClear}
|
||||
size="tiny"
|
||||
color="secondary"
|
||||
shape="round">
|
||||
<ButtonIcon icon={XIcon} />
|
||||
</Button>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import {useRef, useState} from 'react'
|
||||
import {type TextInput, View} from 'react-native'
|
||||
import {useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button, ButtonIcon} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import * as TextField from '#/components/forms/TextField'
|
||||
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
|
||||
|
||||
/**
|
||||
* A text input with a clear (X) button inside it on the right. The input stays
|
||||
* uncontrolled (defaultValue + imperative clear) per the codebase's preference;
|
||||
* local state only drives whether the clear button is shown.
|
||||
*/
|
||||
export function ClearableInput({
|
||||
label,
|
||||
defaultValue,
|
||||
placeholder,
|
||||
onChangeText,
|
||||
onSubmitEditing,
|
||||
}: {
|
||||
label: string
|
||||
defaultValue: string
|
||||
placeholder?: string
|
||||
onChangeText: (text: string) => void
|
||||
onSubmitEditing?: () => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {t: l} = useLingui()
|
||||
const inputRef = useRef<TextInput>(null)
|
||||
const [showClear, setShowClear] = useState(defaultValue.length > 0)
|
||||
|
||||
return (
|
||||
<View style={[a.relative]}>
|
||||
<TextField.Root>
|
||||
<Dialog.Input
|
||||
inputRef={inputRef}
|
||||
label={label}
|
||||
defaultValue={defaultValue}
|
||||
placeholder={placeholder}
|
||||
keyboardAppearance={t.scheme}
|
||||
autoCorrect={false}
|
||||
autoComplete="off"
|
||||
autoCapitalize="none"
|
||||
style={[a.pr_2xl]}
|
||||
onChangeText={text => {
|
||||
setShowClear(text.length > 0)
|
||||
onChangeText(text)
|
||||
}}
|
||||
onSubmitEditing={onSubmitEditing}
|
||||
/>
|
||||
</TextField.Root>
|
||||
|
||||
{showClear && (
|
||||
<View
|
||||
style={[a.absolute, a.justify_center, {top: 0, bottom: 0, right: 8}]}>
|
||||
<Button
|
||||
label={l`Clear`}
|
||||
onPress={() => {
|
||||
inputRef.current?.clear()
|
||||
setShowClear(false)
|
||||
onChangeText('')
|
||||
}}
|
||||
size="tiny"
|
||||
color="secondary"
|
||||
shape="round">
|
||||
<ButtonIcon icon={XIcon} />
|
||||
</Button>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import {View} from 'react-native'
|
||||
import {Trans, useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {atoms as a, platform, useTheme} from '#/alf'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import {
|
||||
ChevronBottom_Stroke2_Corner0_Rounded as ChevronDownIcon,
|
||||
ChevronTopBottom_Stroke2_Corner0_Rounded as ChevronUpDownIcon,
|
||||
} from '#/components/icons/Chevron'
|
||||
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
|
||||
import * as Menu from '#/components/Menu'
|
||||
import {AutocompleteInput} from './AutocompleteInput'
|
||||
import {ClearableInput} from './ClearableInput'
|
||||
import {useFilterFieldLabels} from './hooks'
|
||||
import {type AdvancedFilter, FILTER_FIELDS, HANDLE_FIELDS} from './utils'
|
||||
|
||||
export function FilterBlock({
|
||||
filter,
|
||||
onChange,
|
||||
onRemove,
|
||||
onSubmitEditing,
|
||||
}: {
|
||||
filter: AdvancedFilter
|
||||
onChange: (patch: Partial<AdvancedFilter>) => void
|
||||
onRemove: () => void
|
||||
onSubmitEditing?: () => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {t: l} = useLingui()
|
||||
const labels = useFilterFieldLabels()
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.gap_sm,
|
||||
a.p_md,
|
||||
a.rounded_md,
|
||||
a.border,
|
||||
t.atoms.border_contrast_low,
|
||||
]}>
|
||||
<View style={[a.flex_row, a.gap_sm, a.align_center]}>
|
||||
<Menu.Root>
|
||||
<Menu.Trigger
|
||||
label={l`Include or exclude matching posts (currently: ${
|
||||
filter.mode === 'exclude'
|
||||
? l({message: 'Exclude', comment: 'Advanced search filter'})
|
||||
: l({message: 'Include', comment: 'Advanced search filter'})
|
||||
})`}>
|
||||
{({props}) => (
|
||||
<Button
|
||||
{...props}
|
||||
label={props.accessibilityLabel}
|
||||
size="small"
|
||||
color="secondary">
|
||||
<ButtonText>
|
||||
{filter.mode === 'exclude'
|
||||
? l({message: 'Exclude', comment: 'Advanced search filter'})
|
||||
: l({
|
||||
message: 'Include',
|
||||
comment: 'Advanced search filter',
|
||||
})}
|
||||
</ButtonText>
|
||||
<ButtonIcon
|
||||
icon={platform({
|
||||
native: ChevronUpDownIcon,
|
||||
default: ChevronDownIcon,
|
||||
})}
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
</Menu.Trigger>
|
||||
<Menu.Outer>
|
||||
<Menu.Group>
|
||||
<Menu.Item
|
||||
label={l({
|
||||
message: 'Include',
|
||||
comment: 'Advanced search filter',
|
||||
})}
|
||||
onPress={() => onChange({mode: 'include'})}>
|
||||
<Menu.ItemText>
|
||||
<Trans>Include</Trans>
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemRadio selected={filter.mode === 'include'} />
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
label={l({
|
||||
message: 'Exclude',
|
||||
comment: 'Advanced search filter',
|
||||
})}
|
||||
onPress={() => onChange({mode: 'exclude'})}>
|
||||
<Menu.ItemText>
|
||||
<Trans>Exclude</Trans>
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemRadio selected={filter.mode === 'exclude'} />
|
||||
</Menu.Item>
|
||||
</Menu.Group>
|
||||
</Menu.Outer>
|
||||
</Menu.Root>
|
||||
|
||||
<Menu.Root>
|
||||
<Menu.Trigger
|
||||
label={l`Select filter type (currently: ${labels[filter.field].title})`}>
|
||||
{({props}) => (
|
||||
<Button
|
||||
{...props}
|
||||
label={props.accessibilityLabel}
|
||||
size="small"
|
||||
color="secondary">
|
||||
<ButtonText>{labels[filter.field].title}</ButtonText>
|
||||
<ButtonIcon
|
||||
icon={platform({
|
||||
native: ChevronUpDownIcon,
|
||||
default: ChevronDownIcon,
|
||||
})}
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
</Menu.Trigger>
|
||||
<Menu.Outer>
|
||||
<Menu.Group>
|
||||
{FILTER_FIELDS.map(field => (
|
||||
<Menu.Item
|
||||
key={field}
|
||||
label={labels[field].title}
|
||||
/*
|
||||
* Switching the field type clears any text entered for the
|
||||
* previous type, since values rarely carry over meaningfully
|
||||
* (e.g. a handle is not a domain). Changing include/exclude
|
||||
* mode leaves the text intact - it's the same field.
|
||||
*/
|
||||
onPress={() => onChange({field, value: ''})}>
|
||||
<Menu.ItemText>{labels[field].title}</Menu.ItemText>
|
||||
<Menu.ItemRadio selected={filter.field === field} />
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu.Group>
|
||||
</Menu.Outer>
|
||||
</Menu.Root>
|
||||
|
||||
<View style={[a.flex_1]} />
|
||||
|
||||
<Button
|
||||
label={l({
|
||||
message: 'Remove filter',
|
||||
comment: 'Advanced search filter',
|
||||
})}
|
||||
size="small"
|
||||
color="secondary"
|
||||
shape="round"
|
||||
onPress={onRemove}>
|
||||
<ButtonIcon icon={XIcon} />
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
{HANDLE_FIELDS.has(filter.field) ? (
|
||||
<AutocompleteInput
|
||||
/*
|
||||
* Remount on field-type change so the input resets to the cleared
|
||||
* value; mode changes keep the same field and so preserve the text.
|
||||
*/
|
||||
key={filter.field}
|
||||
label={labels[filter.field].label}
|
||||
value={filter.value}
|
||||
onChangeText={text => onChange({value: text})}
|
||||
onSubmitEditing={onSubmitEditing}
|
||||
/>
|
||||
) : (
|
||||
<ClearableInput
|
||||
/*
|
||||
* The input is uncontrolled (defaultValue), so remount on field-type
|
||||
* change to reset it; mode changes keep the same field and text.
|
||||
*/
|
||||
key={filter.field}
|
||||
label={labels[filter.field].label}
|
||||
defaultValue={filter.value}
|
||||
onChangeText={text => onChange({value: text})}
|
||||
onSubmitEditing={onSubmitEditing}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import {Trans, useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {platform} from '#/alf'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import {
|
||||
ChevronBottom_Stroke2_Corner0_Rounded as ChevronDownIcon,
|
||||
ChevronTopBottom_Stroke2_Corner0_Rounded as ChevronUpDownIcon,
|
||||
} from '#/components/icons/Chevron'
|
||||
import * as Menu from '#/components/Menu'
|
||||
import {type FollowingFilter} from './utils'
|
||||
|
||||
export function FollowingDropdown({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: FollowingFilter
|
||||
onChange: (value: FollowingFilter) => void
|
||||
}) {
|
||||
const {t: l} = useLingui()
|
||||
|
||||
const options: {value: FollowingFilter; label: string}[] = [
|
||||
{value: 'everyone', label: l`Everyone`},
|
||||
{value: 'following', label: l`People you follow`},
|
||||
]
|
||||
const currentLabel =
|
||||
options.find(o => o.value === value)?.label ?? l`Everyone`
|
||||
|
||||
return (
|
||||
<Menu.Root>
|
||||
<Menu.Trigger label={l`Filter by author (currently: ${currentLabel})`}>
|
||||
{({props}) => (
|
||||
<Button
|
||||
{...props}
|
||||
label={props.accessibilityLabel}
|
||||
size="small"
|
||||
color="secondary">
|
||||
<ButtonText>{currentLabel}</ButtonText>
|
||||
<ButtonIcon
|
||||
icon={platform({
|
||||
native: ChevronUpDownIcon,
|
||||
default: ChevronDownIcon,
|
||||
})}
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
</Menu.Trigger>
|
||||
<Menu.Outer>
|
||||
<Menu.LabelText>
|
||||
<Trans>Filter by author</Trans>
|
||||
</Menu.LabelText>
|
||||
<Menu.Group>
|
||||
{options.map(option => (
|
||||
<Menu.Item
|
||||
key={option.value}
|
||||
label={option.label}
|
||||
onPress={() => onChange(option.value)}>
|
||||
<Menu.ItemText>{option.label}</Menu.ItemText>
|
||||
<Menu.ItemRadio selected={value === option.value} />
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu.Group>
|
||||
</Menu.Outer>
|
||||
</Menu.Root>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import {Trans, useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {platform} from '#/alf'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import {
|
||||
ChevronBottom_Stroke2_Corner0_Rounded as ChevronDownIcon,
|
||||
ChevronTopBottom_Stroke2_Corner0_Rounded as ChevronUpDownIcon,
|
||||
} from '#/components/icons/Chevron'
|
||||
import * as Menu from '#/components/Menu'
|
||||
import {type MediaFilter} from './utils'
|
||||
|
||||
export function MediaDropdown({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: MediaFilter
|
||||
onChange: (value: MediaFilter) => void
|
||||
}) {
|
||||
const {t: l} = useLingui()
|
||||
|
||||
const options: {value: MediaFilter; label: string}[] = [
|
||||
{value: 'all', label: l`All posts`},
|
||||
{value: 'media', label: l`Only posts with media`},
|
||||
{value: 'video', label: l`Only posts with videos`},
|
||||
]
|
||||
const currentLabel =
|
||||
options.find(o => o.value === value)?.label ?? l`All posts`
|
||||
|
||||
return (
|
||||
<Menu.Root>
|
||||
<Menu.Trigger label={l`Filter by media (currently: ${currentLabel})`}>
|
||||
{({props}) => (
|
||||
<Button
|
||||
{...props}
|
||||
label={props.accessibilityLabel}
|
||||
size="small"
|
||||
color="secondary">
|
||||
<ButtonText>{currentLabel}</ButtonText>
|
||||
<ButtonIcon
|
||||
icon={platform({
|
||||
native: ChevronUpDownIcon,
|
||||
default: ChevronDownIcon,
|
||||
})}
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
</Menu.Trigger>
|
||||
<Menu.Outer>
|
||||
<Menu.LabelText>
|
||||
<Trans>Filter by media</Trans>
|
||||
</Menu.LabelText>
|
||||
<Menu.Group>
|
||||
{options.map(option => (
|
||||
<Menu.Item
|
||||
key={option.value}
|
||||
label={option.label}
|
||||
onPress={() => onChange(option.value)}>
|
||||
<Menu.ItemText>{option.label}</Menu.ItemText>
|
||||
<Menu.ItemRadio selected={value === option.value} />
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu.Group>
|
||||
</Menu.Outer>
|
||||
</Menu.Root>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import {Trans, useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {platform} from '#/alf'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import {
|
||||
ChevronBottom_Stroke2_Corner0_Rounded as ChevronDownIcon,
|
||||
ChevronTopBottom_Stroke2_Corner0_Rounded as ChevronUpDownIcon,
|
||||
} from '#/components/icons/Chevron'
|
||||
import * as Menu from '#/components/Menu'
|
||||
import {type RepliesFilter} from './utils'
|
||||
|
||||
export function RepliesDropdown({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: RepliesFilter
|
||||
onChange: (value: RepliesFilter) => void
|
||||
}) {
|
||||
const {t: l} = useLingui()
|
||||
|
||||
const options: {value: RepliesFilter; label: string}[] = [
|
||||
{value: 'all', label: l`Posts and replies`},
|
||||
{value: 'none', label: l`No replies`},
|
||||
{value: 'only', label: l`Only replies`},
|
||||
]
|
||||
const currentLabel = options.find(o => o.value === value)?.label ?? l`All`
|
||||
|
||||
return (
|
||||
<Menu.Root>
|
||||
<Menu.Trigger
|
||||
label={l`Include posts and/or replies (currently: ${currentLabel})`}>
|
||||
{({props}) => (
|
||||
<Button
|
||||
{...props}
|
||||
label={props.accessibilityLabel}
|
||||
size="small"
|
||||
color="secondary">
|
||||
<ButtonText>{currentLabel}</ButtonText>
|
||||
<ButtonIcon
|
||||
icon={platform({
|
||||
native: ChevronUpDownIcon,
|
||||
default: ChevronDownIcon,
|
||||
})}
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
</Menu.Trigger>
|
||||
<Menu.Outer>
|
||||
<Menu.LabelText>
|
||||
<Trans>Include these results</Trans>
|
||||
</Menu.LabelText>
|
||||
<Menu.Group>
|
||||
{options.map(option => (
|
||||
<Menu.Item
|
||||
key={option.value}
|
||||
label={option.label}
|
||||
onPress={() => onChange(option.value)}>
|
||||
<Menu.ItemText>{option.label}</Menu.ItemText>
|
||||
<Menu.ItemRadio selected={value === option.value} />
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu.Group>
|
||||
</Menu.Outer>
|
||||
</Menu.Root>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
import {describe, expect, it} from '@jest/globals'
|
||||
|
||||
import {
|
||||
makeFilter,
|
||||
parseAdvancedSearch,
|
||||
serializeAdvancedSearch,
|
||||
} from '#/screens/Search/components/AdvancedSearchDialog/utils'
|
||||
import {type SearchFilters} from '#/screens/Search/searchParams'
|
||||
|
||||
const emptySerializeState = {
|
||||
query: '',
|
||||
exactPhrase: '',
|
||||
negatedWords: '',
|
||||
language: '',
|
||||
replies: 'all' as const,
|
||||
media: 'all' as const,
|
||||
following: 'everyone' as const,
|
||||
dateSince: '',
|
||||
dateSinceActive: false,
|
||||
dateUntil: '',
|
||||
dateUntilActive: false,
|
||||
filters: [],
|
||||
}
|
||||
|
||||
describe(`AdvancedSearchDialog serialize/parse`, () => {
|
||||
it(`splits free text into q and structured fields into filters`, () => {
|
||||
const state = parseAdvancedSearch('hello "exact phrase" -spam', {
|
||||
author: 'alice',
|
||||
domain: 'bsky.app',
|
||||
tag: 'atproto bluesky',
|
||||
lang: 'en',
|
||||
since: '2024-01-01',
|
||||
media: 'true',
|
||||
replies: 'none',
|
||||
})
|
||||
|
||||
expect(state.query).toBe('hello')
|
||||
expect(state.exactPhrase).toBe('exact phrase')
|
||||
expect(state.negatedWords).toBe('spam')
|
||||
expect(state.language).toBe('en')
|
||||
expect(state.since).toBe('2024-01-01')
|
||||
expect(state.media).toBe('media')
|
||||
expect(state.replies).toBe('none')
|
||||
// Handle fields (authors/mentions) get a trailing space on parse so the
|
||||
// typeahead stays closed until the user types; it's trimmed on serialize.
|
||||
expect(state.filters.find(f => f.field === 'authors')?.value).toBe('alice ')
|
||||
expect(state.filters.find(f => f.field === 'domains')?.value).toBe(
|
||||
'bsky.app',
|
||||
)
|
||||
expect(state.filters.find(f => f.field === 'tags')?.value).toBe(
|
||||
'atproto bluesky',
|
||||
)
|
||||
})
|
||||
|
||||
it(`lifts operators typed into the query box into include rows`, () => {
|
||||
const state = parseAdvancedSearch(
|
||||
'chat from:bsky.app to:alice domain:example.com url:example.com/x #atproto since:2024-01-01 until:2024-02-01',
|
||||
{},
|
||||
)
|
||||
|
||||
// Operators are stripped from the free text.
|
||||
expect(state.query).toBe('chat')
|
||||
|
||||
const findRow = (field: string) =>
|
||||
state.filters.find(f => f.field === field && f.mode === 'include')?.value
|
||||
// Handle fields get a trailing space on parse (trimmed on serialize).
|
||||
expect(findRow('authors')).toBe('bsky.app ')
|
||||
expect(findRow('mentions')).toBe('alice ')
|
||||
expect(findRow('domains')).toBe('example.com')
|
||||
expect(findRow('urls')).toBe('example.com/x')
|
||||
expect(findRow('tags')).toBe('atproto')
|
||||
expect(state.since).toBe('2024-01-01')
|
||||
expect(state.until).toBe('2024-02-01')
|
||||
})
|
||||
|
||||
it(`merges a query-box operator with the matching filter param`, () => {
|
||||
const state = parseAdvancedSearch('hi from:bob', {author: 'alice'})
|
||||
expect(state.query).toBe('hi')
|
||||
expect(
|
||||
state.filters.find(f => f.field === 'authors' && f.mode === 'include')
|
||||
?.value,
|
||||
).toBe('alice bob ')
|
||||
})
|
||||
|
||||
it(`round-trips state -> {q, filters} -> state`, () => {
|
||||
const q = 'hello "exact phrase" -spam'
|
||||
const filters: SearchFilters = {
|
||||
author: 'alice',
|
||||
domain: 'bsky.app',
|
||||
url: 'bsky.app/post',
|
||||
tag: 'atproto bluesky',
|
||||
lang: 'en',
|
||||
since: '2024-01-01',
|
||||
until: '2024-02-01',
|
||||
media: 'true',
|
||||
following: 'true',
|
||||
replies: 'only',
|
||||
}
|
||||
|
||||
const state = parseAdvancedSearch(q, filters)
|
||||
const out = serializeAdvancedSearch({
|
||||
query: state.query,
|
||||
exactPhrase: state.exactPhrase,
|
||||
negatedWords: state.negatedWords,
|
||||
language: state.language,
|
||||
replies: state.replies,
|
||||
media: state.media,
|
||||
following: state.following,
|
||||
dateSince: state.since,
|
||||
dateSinceActive: !!state.since,
|
||||
dateUntil: state.until,
|
||||
dateUntilActive: !!state.until,
|
||||
filters: state.filters,
|
||||
})
|
||||
|
||||
expect(out.q).toBe(q)
|
||||
expect(out.filters).toEqual(filters)
|
||||
})
|
||||
|
||||
it(`maps the video media filter to the video param on serialize`, () => {
|
||||
const out = serializeAdvancedSearch({
|
||||
...emptySerializeState,
|
||||
media: 'video',
|
||||
})
|
||||
expect(out.filters.video).toBe('true')
|
||||
expect(out.filters.media).toBeUndefined()
|
||||
})
|
||||
|
||||
it(`parses the video param into the video media filter`, () => {
|
||||
const state = parseAdvancedSearch('', {video: 'true'})
|
||||
expect(state.media).toBe('video')
|
||||
})
|
||||
|
||||
it(`maps the following filter to the following param on serialize`, () => {
|
||||
const out = serializeAdvancedSearch({
|
||||
...emptySerializeState,
|
||||
following: 'following',
|
||||
})
|
||||
expect(out.filters.following).toBe('true')
|
||||
})
|
||||
|
||||
it(`leaves the following param unset for everyone`, () => {
|
||||
const out = serializeAdvancedSearch({
|
||||
...emptySerializeState,
|
||||
following: 'everyone',
|
||||
})
|
||||
expect(out.filters.following).toBeUndefined()
|
||||
})
|
||||
|
||||
it(`parses the following param into the following filter`, () => {
|
||||
expect(parseAdvancedSearch('', {following: 'true'}).following).toBe(
|
||||
'following',
|
||||
)
|
||||
expect(parseAdvancedSearch('', {}).following).toBe('everyone')
|
||||
})
|
||||
|
||||
it(`strips redundant markers from filter values on serialize`, () => {
|
||||
const state = parseAdvancedSearch('', {author: 'alice', tag: 'atproto'})
|
||||
// simulate a user typing the marker explicitly
|
||||
state.filters = state.filters.map(f =>
|
||||
f.field === 'authors'
|
||||
? {...f, value: '@alice'}
|
||||
: f.field === 'tags'
|
||||
? {...f, value: '#atproto'}
|
||||
: f,
|
||||
)
|
||||
const out = serializeAdvancedSearch({
|
||||
query: '',
|
||||
exactPhrase: '',
|
||||
negatedWords: '',
|
||||
language: '',
|
||||
replies: 'all',
|
||||
media: 'all',
|
||||
following: 'everyone',
|
||||
dateSince: '',
|
||||
dateSinceActive: false,
|
||||
dateUntil: '',
|
||||
dateUntilActive: false,
|
||||
filters: state.filters,
|
||||
})
|
||||
expect(out.filters.author).toBe('alice')
|
||||
expect(out.filters.tag).toBe('atproto')
|
||||
})
|
||||
|
||||
it(`merges multiple filter rows of the same field on serialize`, () => {
|
||||
const out = serializeAdvancedSearch({
|
||||
...emptySerializeState,
|
||||
filters: [
|
||||
makeFilter('authors', 'alice'),
|
||||
makeFilter('authors', 'bob carol'),
|
||||
],
|
||||
})
|
||||
expect(out.filters.author).toBe('alice bob carol')
|
||||
})
|
||||
|
||||
it(`dedupes duplicate values across rows and within a row on serialize`, () => {
|
||||
const out = serializeAdvancedSearch({
|
||||
...emptySerializeState,
|
||||
filters: [
|
||||
makeFilter('authors', 'alice'),
|
||||
makeFilter('authors', 'alice bob'),
|
||||
makeFilter('tags', 'cats cats'),
|
||||
],
|
||||
})
|
||||
expect(out.filters.author).toBe('alice bob')
|
||||
expect(out.filters.tag).toBe('cats')
|
||||
})
|
||||
|
||||
it(`collapses a merged param back into a single filter row on parse`, () => {
|
||||
const state = parseAdvancedSearch('', {author: 'alice bob carol'})
|
||||
const authorRows = state.filters.filter(f => f.field === 'authors')
|
||||
expect(authorRows).toHaveLength(1)
|
||||
// Handle fields get a trailing space on parse (trimmed on serialize).
|
||||
expect(authorRows[0].value).toBe('alice bob carol ')
|
||||
})
|
||||
|
||||
it(`routes exclude-mode rows to the exclude* params on serialize`, () => {
|
||||
const out = serializeAdvancedSearch({
|
||||
...emptySerializeState,
|
||||
filters: [
|
||||
makeFilter('authors', 'alice', 'include'),
|
||||
makeFilter('authors', 'bob', 'exclude'),
|
||||
makeFilter('tags', 'spam', 'exclude'),
|
||||
],
|
||||
})
|
||||
expect(out.filters.author).toBe('alice')
|
||||
expect(out.filters.excludeAuthor).toBe('bob')
|
||||
expect(out.filters.excludeTag).toBe('spam')
|
||||
})
|
||||
|
||||
it(`merges multiple rows of the same field and mode independently`, () => {
|
||||
const out = serializeAdvancedSearch({
|
||||
...emptySerializeState,
|
||||
filters: [
|
||||
makeFilter('domains', 'a.com', 'include'),
|
||||
makeFilter('domains', 'b.com', 'include'),
|
||||
makeFilter('domains', 'c.com', 'exclude'),
|
||||
makeFilter('domains', 'd.com', 'exclude'),
|
||||
],
|
||||
})
|
||||
expect(out.filters.domain).toBe('a.com b.com')
|
||||
expect(out.filters.excludeDomain).toBe('c.com d.com')
|
||||
})
|
||||
|
||||
it(`builds exclude-mode rows from exclude* params on parse`, () => {
|
||||
const state = parseAdvancedSearch('', {
|
||||
author: 'alice',
|
||||
excludeAuthor: 'bob',
|
||||
excludeTag: 'spam',
|
||||
})
|
||||
const includeAuthor = state.filters.find(
|
||||
f => f.field === 'authors' && f.mode === 'include',
|
||||
)
|
||||
const excludeAuthor = state.filters.find(
|
||||
f => f.field === 'authors' && f.mode === 'exclude',
|
||||
)
|
||||
const excludeTag = state.filters.find(
|
||||
f => f.field === 'tags' && f.mode === 'exclude',
|
||||
)
|
||||
// Handle fields get a trailing space on parse (trimmed on serialize).
|
||||
expect(includeAuthor?.value).toBe('alice ')
|
||||
expect(excludeAuthor?.value).toBe('bob ')
|
||||
expect(excludeTag?.value).toBe('spam')
|
||||
})
|
||||
|
||||
it(`round-trips a mix of include and exclude filters`, () => {
|
||||
const filters: SearchFilters = {
|
||||
author: 'alice',
|
||||
excludeAuthor: 'bob',
|
||||
domain: 'bsky.app',
|
||||
excludeTag: 'spam',
|
||||
}
|
||||
const state = parseAdvancedSearch('', filters)
|
||||
const out = serializeAdvancedSearch({
|
||||
...emptySerializeState,
|
||||
filters: state.filters,
|
||||
})
|
||||
expect(out.filters).toEqual(filters)
|
||||
})
|
||||
|
||||
it(`leaves a negated phrase in the query instead of parsing it`, () => {
|
||||
const state = parseAdvancedSearch('-"exact phrase"', {})
|
||||
expect(state.query).toBe('-"exact phrase"')
|
||||
expect(state.negatedWords).toBe('')
|
||||
expect(state.exactPhrase).toBe('')
|
||||
})
|
||||
|
||||
it(`leaves a phrase with embedded quotes in the query`, () => {
|
||||
const state = parseAdvancedSearch('"say "hi""', {})
|
||||
expect(state.query).toBe('"say "hi""')
|
||||
expect(state.exactPhrase).toBe('')
|
||||
})
|
||||
|
||||
it(`moves quoted negatedWords input to the query verbatim on serialize`, () => {
|
||||
const out = serializeAdvancedSearch({
|
||||
...emptySerializeState,
|
||||
query: 'cats',
|
||||
negatedWords: '"foo" bar',
|
||||
})
|
||||
// "foo" is unexpected -> verbatim; bar is simple -> negated.
|
||||
expect(out.q).toBe('cats "foo" -bar')
|
||||
})
|
||||
|
||||
it(`leaves an OR group in the query instead of parsing it`, () => {
|
||||
const state = parseAdvancedSearch('hello (cats OR dogs)', {})
|
||||
expect(state.query).toBe('hello (cats OR dogs)')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,65 @@
|
||||
import {useState} from 'react'
|
||||
import {useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {type FilterField} from './utils'
|
||||
|
||||
export function useFilterFieldLabels(): Record<
|
||||
FilterField,
|
||||
{
|
||||
title: string
|
||||
label: string
|
||||
}
|
||||
> {
|
||||
const {t: l} = useLingui()
|
||||
|
||||
const hashtags = [
|
||||
'bluesky atproto',
|
||||
l({
|
||||
message: 'bloomscrolling booksky',
|
||||
comment: 'Advanced search: Examples of hashtags',
|
||||
}),
|
||||
]
|
||||
|
||||
// eslint-disable-next-line react/hook-use-state
|
||||
const [hashtagIndex] = useState(() =>
|
||||
Math.floor(Math.random() * hashtags.length),
|
||||
)
|
||||
|
||||
return {
|
||||
authors: {
|
||||
title: l({
|
||||
message: 'From these people',
|
||||
comment: 'Advanced search filter',
|
||||
}),
|
||||
label: 'bsky.app atproto.com',
|
||||
},
|
||||
mentions: {
|
||||
title: l({
|
||||
message: 'Mentions of these people',
|
||||
comment: 'Advanced search filter',
|
||||
}),
|
||||
label: 'bsky.app atproto.com',
|
||||
},
|
||||
tags: {
|
||||
title: l({
|
||||
message: 'These hashtags',
|
||||
comment: 'Advanced search filter',
|
||||
}),
|
||||
label: hashtags[hashtagIndex],
|
||||
},
|
||||
domains: {
|
||||
title: l({
|
||||
message: 'These domains',
|
||||
comment: 'Advanced search filter',
|
||||
}),
|
||||
label: 'bsky.app atproto.com',
|
||||
},
|
||||
urls: {
|
||||
title: l({
|
||||
message: 'These URLs',
|
||||
comment: 'Advanced search filter',
|
||||
}),
|
||||
label: 'bsky.app atproto.com',
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,464 @@
|
||||
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 {FollowingDropdown} from './FollowingDropdown'
|
||||
import {MediaDropdown} from './MediaDropdown'
|
||||
import {RepliesDropdown} from './RepliesDropdown'
|
||||
import {
|
||||
type AdvancedFilter,
|
||||
type FollowingFilter,
|
||||
makeFilter,
|
||||
type MediaFilter,
|
||||
parseAdvancedSearch,
|
||||
type RepliesFilter,
|
||||
serializeAdvancedSearch,
|
||||
} from './utils'
|
||||
|
||||
const MAX_FILTERS = 20
|
||||
|
||||
export function AdvancedSearchDialog({
|
||||
q,
|
||||
filters,
|
||||
onSubmit,
|
||||
}: {
|
||||
q: string
|
||||
filters: SearchFilters
|
||||
onSubmit: (q: string, filters: SearchFilters) => void
|
||||
}) {
|
||||
const ax = useAnalytics()
|
||||
const {t: l} = useLingui()
|
||||
const t = useTheme()
|
||||
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
|
||||
label={l`Open advanced search options`}
|
||||
size="small"
|
||||
color="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>Advanced search</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
{filtersActive && (
|
||||
<View
|
||||
accessible={false}
|
||||
style={[
|
||||
a.absolute,
|
||||
a.rounded_full,
|
||||
{
|
||||
top: -2,
|
||||
right: -2,
|
||||
width: 10,
|
||||
height: 10,
|
||||
backgroundColor: t.palette.primary_500,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</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<FollowingFilter>(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 advanced search options`}
|
||||
contentContainerStyle={[a.px_0, a.pt_0]}
|
||||
header={
|
||||
<Dialog.Header renderLeft={cancelButton} renderRight={searchButton}>
|
||||
<Dialog.HeaderText>
|
||||
<Trans>Advanced search</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: 'bluesky atproto',
|
||||
comment:
|
||||
'Advanced search: Example of an “all of these words” search',
|
||||
})}
|
||||
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>This exact phrase</Trans>
|
||||
</TextField.LabelText>
|
||||
<ClearableInput
|
||||
label={l`This exact phrase`}
|
||||
defaultValue={exactPhrase}
|
||||
placeholder={l({
|
||||
message: 'what’s up',
|
||||
comment: 'Advanced search: Example of an “exact phrase” search',
|
||||
})}
|
||||
onChangeText={setExactPhrase}
|
||||
onSubmitEditing={handlePressSearch}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<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 an “none of these words” search',
|
||||
})}
|
||||
onChangeText={setNegatedWords}
|
||||
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>Include</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>From</Trans>
|
||||
</Text>
|
||||
<View style={[a.flex_row]}>
|
||||
<FollowingDropdown 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>
|
||||
You’ve 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 an additional 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
import {
|
||||
extractSearchPostsParams,
|
||||
tokenizeQuery,
|
||||
} from '#/state/queries/search-posts-params'
|
||||
import {type SearchFilters} from '#/screens/Search/searchParams'
|
||||
|
||||
export type RepliesFilter = 'all' | 'none' | 'only'
|
||||
|
||||
/**
|
||||
* Media filter is mutually exclusive: a post matches at most one of these. It
|
||||
* serializes into the separate media/video sibling params (only one is ever
|
||||
* set at a time).
|
||||
*/
|
||||
export type MediaFilter = 'all' | 'media' | 'video'
|
||||
|
||||
/**
|
||||
* Whether to limit results to authors the user follows. Serializes into the
|
||||
* `following` sibling param ('following' -> following:true, everyone -> unset).
|
||||
*/
|
||||
export type FollowingFilter = 'everyone' | 'following'
|
||||
|
||||
export type FilterField = 'authors' | 'mentions' | 'domains' | 'urls' | 'tags'
|
||||
|
||||
export const FILTER_FIELDS: FilterField[] = [
|
||||
'authors',
|
||||
'mentions',
|
||||
'domains',
|
||||
'urls',
|
||||
'tags',
|
||||
]
|
||||
|
||||
/** Fields whose values are user handles, so they get handle typeahead. */
|
||||
export const HANDLE_FIELDS = new Set<FilterField>(['authors', 'mentions'])
|
||||
|
||||
/**
|
||||
* Whether a filter includes or excludes matching posts. Exclude is a v2-only
|
||||
* capability; it serializes into the exclude* sibling params and is dropped on
|
||||
* the search v1 path.
|
||||
*/
|
||||
export type FilterMode = 'include' | 'exclude'
|
||||
|
||||
export type AdvancedFilter = {
|
||||
id: string
|
||||
field: FilterField
|
||||
mode: FilterMode
|
||||
value: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Monotonic counter for filter row ids, owned here so both this module and the
|
||||
* component create rows through the same source. Imported `let` bindings are
|
||||
* read-only, so callers must use makeFilter rather than incrementing directly.
|
||||
*/
|
||||
let nextFilterId = 0
|
||||
|
||||
/** Creates a new filter row with a unique id. */
|
||||
export function makeFilter(
|
||||
field: FilterField,
|
||||
value: string = '',
|
||||
mode: FilterMode = 'include',
|
||||
): AdvancedFilter {
|
||||
return {id: `filter-${nextFilterId++}`, field, mode, value}
|
||||
}
|
||||
|
||||
/**
|
||||
* A marker character users commonly type that is redundant with the field's
|
||||
* stored form, e.g. "#cats" or "@alice"; stripped on serialize so we don't
|
||||
* store "#cats" or "@alice" in the param value.
|
||||
*/
|
||||
const FIELD_MARKERS: Partial<Record<FilterField, string>> = {
|
||||
authors: '@',
|
||||
mentions: '@',
|
||||
tags: '#',
|
||||
}
|
||||
|
||||
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/
|
||||
|
||||
function isValidDate(value: string): boolean {
|
||||
if (!DATE_RE.test(value)) return false
|
||||
const d = new Date(value + 'T00:00:00')
|
||||
return !isNaN(d.getTime())
|
||||
}
|
||||
|
||||
/**
|
||||
* The dialog's full internal state. Free-text fields (query/exactPhrase/
|
||||
* negatedWords) are derived from `q`; everything else comes from the structured
|
||||
* filter params.
|
||||
*/
|
||||
export type DialogState = {
|
||||
query: string
|
||||
exactPhrase: string
|
||||
negatedWords: string
|
||||
language: string
|
||||
replies: RepliesFilter
|
||||
media: MediaFilter
|
||||
following: FollowingFilter
|
||||
since: string
|
||||
until: string
|
||||
filters: AdvancedFilter[]
|
||||
}
|
||||
|
||||
const WHITESPACE_RE = /\s+/
|
||||
|
||||
/**
|
||||
* Maps a filter field to the structured param key it serializes into, by mode.
|
||||
* Include rows write the base param; exclude rows write the exclude* sibling.
|
||||
*/
|
||||
const FIELD_TO_PARAM: Record<
|
||||
FilterMode,
|
||||
Record<FilterField, keyof SearchFilters>
|
||||
> = {
|
||||
include: {
|
||||
authors: 'author',
|
||||
mentions: 'mentions',
|
||||
domains: 'domain',
|
||||
urls: 'url',
|
||||
tags: 'tag',
|
||||
},
|
||||
exclude: {
|
||||
authors: 'excludeAuthor',
|
||||
mentions: 'excludeMentions',
|
||||
domains: 'excludeDomain',
|
||||
urls: 'excludeUrl',
|
||||
tags: 'excludeTag',
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* A "simple" free-text word - no quotes. Only simple words round-trip cleanly
|
||||
* through the negated-words field, so anything else is left in (or moved to)
|
||||
* the main query rather than coerced into a `-word` token it can't represent.
|
||||
*/
|
||||
function isSimpleWord(word: string): boolean {
|
||||
return word.length > 0 && !word.includes('"')
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the free-text portion of a query (`q`) into the dialog's text fields.
|
||||
* `q` no longer carries structured operators - those arrive separately via
|
||||
* `filters`. A quoted phrase populates "exact phrase" and -negated terms
|
||||
* populate "none of these words" - but only when their contents are simple
|
||||
* words. Anything that wouldn't round-trip (embedded quotes, a negated phrase
|
||||
* like -"a b", etc.) is left verbatim in the main "all of these words" query
|
||||
* text instead of parsed.
|
||||
*/
|
||||
function parseFreeText(raw: string): {
|
||||
query: string
|
||||
exactPhrase: string
|
||||
negatedWords: string
|
||||
} {
|
||||
const queryParts: string[] = []
|
||||
const negatedWords: string[] = []
|
||||
let exactPhrase = ''
|
||||
|
||||
for (const token of tokenizeQuery(raw)) {
|
||||
// "phrase" -> "exact phrase", only if it has no inner quote.
|
||||
if (token.startsWith('"') && token.endsWith('"') && token.length > 1) {
|
||||
const inner = token.slice(1, -1)
|
||||
if (!inner.includes('"')) {
|
||||
exactPhrase = inner
|
||||
continue
|
||||
}
|
||||
}
|
||||
// -word -> "none of these words", only if the negated word is simple.
|
||||
if (token.startsWith('-') && token.length > 1 && !token.includes(':')) {
|
||||
const word = token.slice(1)
|
||||
if (isSimpleWord(word)) {
|
||||
negatedWords.push(word)
|
||||
continue
|
||||
}
|
||||
}
|
||||
queryParts.push(token)
|
||||
}
|
||||
|
||||
return {
|
||||
query: queryParts.join(' '),
|
||||
exactPhrase,
|
||||
negatedWords: negatedWords.join(' '),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Joins two space-delimited value lists, dropping empties and duplicates while
|
||||
* preserving order. Used to fold operators lifted from the query text into the
|
||||
* matching structured filter values without clobbering either source.
|
||||
*/
|
||||
function mergeValues(a?: string, b?: string): string {
|
||||
const seen = new Set<string>()
|
||||
for (const part of `${a ?? ''} ${b ?? ''}`.split(WHITESPACE_RE)) {
|
||||
if (part) seen.add(part)
|
||||
}
|
||||
return [...seen].join(' ')
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the full dialog state from the free-text `q` plus the structured
|
||||
* filter params. Operators typed directly into the query box (e.g. `from:`,
|
||||
* `domain:`, `since:`, `#tag`) are lifted out via extractSearchPostsParams and
|
||||
* folded into the matching include filters/date fields, and removed from the
|
||||
* free text - so the dialog shows them as structured rows rather than raw text.
|
||||
*/
|
||||
export function parseAdvancedSearch(
|
||||
q: string,
|
||||
filters: SearchFilters,
|
||||
): DialogState {
|
||||
/*
|
||||
* Lift recognized operators out of the query text. Only include-mode fields
|
||||
* can be expressed as operators; exclude rows come solely from filter params.
|
||||
*/
|
||||
const lifted = extractSearchPostsParams(q)
|
||||
const freeText = parseFreeText(lifted.q)
|
||||
|
||||
const includeValues: Record<FilterField, string> = {
|
||||
authors: mergeValues(filters.author, lifted.author),
|
||||
mentions: mergeValues(filters.mentions, lifted.mentions),
|
||||
domains: mergeValues(filters.domain, lifted.domain),
|
||||
urls: mergeValues(filters.url, lifted.url),
|
||||
tags: mergeValues(filters.tag, lifted.tag?.join(' ')),
|
||||
}
|
||||
|
||||
const filterRows: AdvancedFilter[] = []
|
||||
const MODES: FilterMode[] = ['include', 'exclude']
|
||||
for (const field of FILTER_FIELDS) {
|
||||
for (const mode of MODES) {
|
||||
const value =
|
||||
mode === 'include'
|
||||
? includeValues[field]
|
||||
: filters[FIELD_TO_PARAM[mode][field]]
|
||||
if (value) {
|
||||
/*
|
||||
* Append a trailing space to handle fields so the typeahead (which
|
||||
* matches the last token) stays closed until the user types. The space
|
||||
* is ignored on serialize, which trims/splits on whitespace.
|
||||
*/
|
||||
filterRows.push(
|
||||
makeFilter(
|
||||
field,
|
||||
HANDLE_FIELDS.has(field) ? `${value} ` : value,
|
||||
mode,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let replies: RepliesFilter = 'all'
|
||||
if (filters.replies === 'none') replies = 'none'
|
||||
else if (filters.replies === 'only') replies = 'only'
|
||||
|
||||
let media: MediaFilter = 'all'
|
||||
if (filters.media === 'true') media = 'media'
|
||||
else if (filters.video === 'true') media = 'video'
|
||||
|
||||
const lang = filters.lang ?? lifted.lang ?? ''
|
||||
const since = filters.since ?? lifted.since
|
||||
const until = filters.until ?? lifted.until
|
||||
|
||||
return {
|
||||
...freeText,
|
||||
language: lang,
|
||||
replies,
|
||||
media,
|
||||
following: filters.following === 'true' ? 'following' : 'everyone',
|
||||
since: since && isValidDate(since) ? since : '',
|
||||
until: until && isValidDate(until) ? until : '',
|
||||
filters: filterRows,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes the dialog state into the free-text `q` string plus the structured
|
||||
* filter params. Free text (all/exact/none words) goes into `q`; everything
|
||||
* else becomes a sibling param.
|
||||
*/
|
||||
export function serializeAdvancedSearch(state: {
|
||||
query: string
|
||||
exactPhrase: string
|
||||
negatedWords: string
|
||||
language: string
|
||||
replies: RepliesFilter
|
||||
media: MediaFilter
|
||||
following: FollowingFilter
|
||||
dateSince: string
|
||||
dateSinceActive: boolean
|
||||
dateUntil: string
|
||||
dateUntilActive: boolean
|
||||
filters: AdvancedFilter[]
|
||||
}): {q: string; filters: SearchFilters} {
|
||||
const parts: string[] = []
|
||||
|
||||
if (state.query.trim()) {
|
||||
parts.push(state.query.trim())
|
||||
}
|
||||
|
||||
/*
|
||||
* "exact phrase" -> a quoted token, but only if it's a clean phrase with no
|
||||
* embedded quotes. Otherwise it can't be safely wrapped, so pass it through
|
||||
* to the query verbatim.
|
||||
*/
|
||||
const exactPhrase = state.exactPhrase.trim()
|
||||
if (exactPhrase) {
|
||||
parts.push(exactPhrase.includes('"') ? exactPhrase : `"${exactPhrase}"`)
|
||||
}
|
||||
|
||||
/*
|
||||
* "none of these words" -> -negated tokens, but only simple words get the `-`
|
||||
* prefix. Anything unexpected passes through to the query verbatim.
|
||||
*/
|
||||
for (const word of state.negatedWords.trim().split(WHITESPACE_RE)) {
|
||||
if (!word) continue
|
||||
const bare = word.replace(/^-+/, '')
|
||||
if (isSimpleWord(bare)) {
|
||||
parts.push(`-${bare}`)
|
||||
} else {
|
||||
parts.push(word)
|
||||
}
|
||||
}
|
||||
|
||||
const filters: SearchFilters = {}
|
||||
|
||||
/*
|
||||
* Each filter row's value is normalized (marker stripped) into space-joined
|
||||
* values under its param key. The row's mode selects the include or exclude
|
||||
* param. Multiple rows of the same field and mode merge into one param,
|
||||
* deduping values across rows (and within a row) so e.g. two `author: alice`
|
||||
* rows don't serialize to "alice alice".
|
||||
*/
|
||||
const valuesByKey = new Map<keyof SearchFilters, Set<string>>()
|
||||
for (const filter of state.filters) {
|
||||
const marker = FIELD_MARKERS[filter.field]
|
||||
const values = filter.value
|
||||
.trim()
|
||||
.split(WHITESPACE_RE)
|
||||
.filter(Boolean)
|
||||
.map(v => (marker && v.startsWith(marker) ? v.slice(1) : v))
|
||||
.filter(Boolean)
|
||||
if (values.length) {
|
||||
const key = FIELD_TO_PARAM[filter.mode][filter.field]
|
||||
const set = valuesByKey.get(key) ?? new Set<string>()
|
||||
for (const value of values) set.add(value)
|
||||
valuesByKey.set(key, set)
|
||||
}
|
||||
}
|
||||
for (const [key, set] of valuesByKey) {
|
||||
filters[key] = [...set].join(' ')
|
||||
}
|
||||
|
||||
if (state.language) filters.lang = state.language
|
||||
if (state.dateSinceActive && state.dateSince) filters.since = state.dateSince
|
||||
if (state.dateUntilActive && state.dateUntil) filters.until = state.dateUntil
|
||||
if (state.replies === 'none') filters.replies = 'none'
|
||||
if (state.replies === 'only') filters.replies = 'only'
|
||||
if (state.media === 'media') filters.media = 'true'
|
||||
else if (state.media === 'video') filters.video = 'true'
|
||||
if (state.following === 'following') filters.following = 'true'
|
||||
|
||||
return {q: parts.join(' '), filters}
|
||||
}
|
||||
Reference in New Issue
Block a user