Add From: Me to advanced search filters (#11126)
This commit is contained in:
+7
-6
@@ -7,20 +7,21 @@ import {
|
||||
ChevronTopBottom_Stroke2_Corner0_Rounded as ChevronUpDownIcon,
|
||||
} from '#/components/icons/Chevron'
|
||||
import * as Menu from '#/components/Menu'
|
||||
import {type FollowingFilter} from './utils'
|
||||
import {type FromFilter} from './utils'
|
||||
|
||||
export function FollowingDropdown({
|
||||
export function FromDropdown({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: FollowingFilter
|
||||
onChange: (value: FollowingFilter) => void
|
||||
value: FromFilter
|
||||
onChange: (value: FromFilter) => void
|
||||
}) {
|
||||
const {t: l} = useLingui()
|
||||
|
||||
const options: {value: FollowingFilter; label: string}[] = [
|
||||
const options: {value: FromFilter; label: string}[] = [
|
||||
{value: 'anyone', label: l`Anyone`},
|
||||
{value: 'following', label: l`People you follow`},
|
||||
{value: 'following', label: l`People I follow`},
|
||||
{value: 'me', label: l`Me`},
|
||||
]
|
||||
const currentLabel = options.find(o => o.value === value)?.label ?? l`Anyone`
|
||||
|
||||
@@ -73,12 +73,68 @@ describe(`AdvancedSearchDialog serialize/parse`, () => {
|
||||
expect(state.until).toBe('2024-02-01')
|
||||
})
|
||||
|
||||
it(`keeps from:me in the query box rather than lifting it into a row`, () => {
|
||||
const state = parseAdvancedSearch('from:me', {})
|
||||
expect(state.query).toBe('from:me')
|
||||
it(`lifts from:me typed into the query box into the "me" following filter`, () => {
|
||||
const state = parseAdvancedSearch('cats from:me', {})
|
||||
expect(state.query).toBe('cats')
|
||||
expect(state.following).toBe('me')
|
||||
expect(state.filters.find(f => f.field === 'authors')).toBeUndefined()
|
||||
})
|
||||
|
||||
it(`promotes the "me" filter from q to a structured filter`, () => {
|
||||
const out = serializeAdvancedSearch({
|
||||
...emptySerializeState,
|
||||
query: 'cats',
|
||||
following: 'me',
|
||||
})
|
||||
expect(out.q).toBe('cats')
|
||||
expect(out.filters.from).toBe('me')
|
||||
expect(out.filters.following).toBeUndefined()
|
||||
})
|
||||
|
||||
it(`strips from:me typed after selecting Me`, () => {
|
||||
const out = serializeAdvancedSearch({
|
||||
...emptySerializeState,
|
||||
query: 'cats from:me',
|
||||
following: 'me',
|
||||
})
|
||||
expect(out.q).toBe('cats')
|
||||
expect(out.filters.from).toBe('me')
|
||||
})
|
||||
|
||||
it(`promotes from:me typed after the dialog opens`, () => {
|
||||
const out = serializeAdvancedSearch({
|
||||
...emptySerializeState,
|
||||
query: 'cats from:me',
|
||||
})
|
||||
expect(out.q).toBe('cats')
|
||||
expect(out.filters.from).toBe('me')
|
||||
})
|
||||
|
||||
it(`keeps a quoted from:me as query text`, () => {
|
||||
const out = serializeAdvancedSearch({
|
||||
...emptySerializeState,
|
||||
query: 'cats "from:me"',
|
||||
})
|
||||
expect(out.q).toBe('cats "from:me"')
|
||||
expect(out.filters.from).toBeUndefined()
|
||||
})
|
||||
|
||||
it(`round-trips cats from:me through the "me" following filter`, () => {
|
||||
const state = parseAdvancedSearch('cats from:me', {})
|
||||
const out = serializeAdvancedSearch({
|
||||
...emptySerializeState,
|
||||
query: state.query,
|
||||
following: state.following,
|
||||
})
|
||||
expect(out.q).toBe('cats')
|
||||
expect(out.filters.from).toBe('me')
|
||||
expect(out.filters.following).toBeUndefined()
|
||||
})
|
||||
|
||||
it(`parses the structured Me filter into the From dropdown`, () => {
|
||||
expect(parseAdvancedSearch('cats', {from: 'me'}).following).toBe('me')
|
||||
})
|
||||
|
||||
it(`merges a query-box operator with the matching filter param`, () => {
|
||||
const state = parseAdvancedSearch('hi from:bob', {author: 'alice'})
|
||||
expect(state.query).toBe('hi')
|
||||
|
||||
@@ -22,12 +22,12 @@ import {SearchLanguageDropdown} from '../SearchLanguageDropdown'
|
||||
import {ClearableDateField, DEFAULT_DATE} from './ClearableDateField'
|
||||
import {ClearableInput} from './ClearableInput'
|
||||
import {FilterBlock} from './FilterBlock'
|
||||
import {FollowingDropdown} from './FollowingDropdown'
|
||||
import {FromDropdown} from './FromDropdown'
|
||||
import {MediaDropdown} from './MediaDropdown'
|
||||
import {RepliesDropdown} from './RepliesDropdown'
|
||||
import {
|
||||
type AdvancedFilter,
|
||||
type FollowingFilter,
|
||||
type FromFilter,
|
||||
makeFilter,
|
||||
type MediaFilter,
|
||||
parseAdvancedSearch,
|
||||
@@ -124,7 +124,7 @@ function DialogInner({
|
||||
|
||||
const [media, setMedia] = useState<MediaFilter>(parsed.media)
|
||||
const [replies, setReplies] = useState<RepliesFilter>(parsed.replies)
|
||||
const [following, setFollowing] = useState<FollowingFilter>(parsed.following)
|
||||
const [following, setFollowing] = useState<FromFilter>(parsed.following)
|
||||
|
||||
/*
|
||||
* The date picker requires a valid date, so these always hold one. The
|
||||
@@ -389,7 +389,9 @@ function DialogInner({
|
||||
t.atoms.text_contrast_medium,
|
||||
a.mb_sm,
|
||||
]}>
|
||||
<Trans>Include</Trans>
|
||||
<Trans comment="Include search results with or without replies">
|
||||
Include
|
||||
</Trans>
|
||||
</Text>
|
||||
<View style={[a.flex_row]}>
|
||||
<RepliesDropdown value={replies} onChange={setReplies} />
|
||||
@@ -403,10 +405,12 @@ function DialogInner({
|
||||
t.atoms.text_contrast_medium,
|
||||
a.mb_sm,
|
||||
]}>
|
||||
<Trans>From</Trans>
|
||||
<Trans comment="Filter search results by a specific post author">
|
||||
From
|
||||
</Trans>
|
||||
</Text>
|
||||
<View style={[a.flex_row]}>
|
||||
<FollowingDropdown value={following} onChange={setFollowing} />
|
||||
<FromDropdown value={following} onChange={setFollowing} />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
extractFromMe,
|
||||
extractSearchPostsParams,
|
||||
tokenizeQuery,
|
||||
} from '#/state/queries/search-posts-params'
|
||||
@@ -14,10 +15,11 @@ export type RepliesFilter = 'all' | 'none' | 'only'
|
||||
export type MediaFilter = 'all' | 'media' | 'video'
|
||||
|
||||
/**
|
||||
* Whether to limit results to authors the user follows. Serializes into the
|
||||
* `following` sibling param ('following' -> following:true, anyone -> unset).
|
||||
* Which authors to limit results to. 'following' serializes into the
|
||||
* `following` sibling param (following=true); 'me' serializes into the `from`
|
||||
* sibling param (from=me); 'anyone' leaves both unset.
|
||||
*/
|
||||
export type FollowingFilter = 'anyone' | 'following'
|
||||
export type FromFilter = 'anyone' | 'following' | 'me'
|
||||
|
||||
export type FilterField = 'authors' | 'mentions' | 'domains' | 'urls' | 'tags'
|
||||
|
||||
@@ -93,7 +95,7 @@ export type DialogState = {
|
||||
language: string
|
||||
replies: RepliesFilter
|
||||
media: MediaFilter
|
||||
following: FollowingFilter
|
||||
following: FromFilter
|
||||
since: string
|
||||
until: string
|
||||
filters: AdvancedFilter[]
|
||||
@@ -141,18 +143,27 @@ function isSimpleWord(word: string): boolean {
|
||||
* 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.
|
||||
* text instead of parsed. A bare `from:me` is pulled out into the `fromMe`
|
||||
* flag, which drives the "Me" author filter (the backend resolves `me` to the
|
||||
* viewer, so it never becomes a structured `author` value).
|
||||
*/
|
||||
function parseFreeText(raw: string): {
|
||||
query: string
|
||||
exactPhrase: string
|
||||
negatedWords: string
|
||||
fromMe: boolean
|
||||
} {
|
||||
const queryParts: string[] = []
|
||||
const negatedWords: string[] = []
|
||||
let exactPhrase = ''
|
||||
let fromMe = false
|
||||
|
||||
for (const token of tokenizeQuery(raw)) {
|
||||
// from:me -> "Me" author filter rather than free text.
|
||||
if (token === 'from:me') {
|
||||
fromMe = true
|
||||
continue
|
||||
}
|
||||
// "phrase" -> "exact phrase", only if it has no inner quote.
|
||||
if (token.startsWith('"') && token.endsWith('"') && token.length > 1) {
|
||||
const inner = token.slice(1, -1)
|
||||
@@ -176,6 +187,7 @@ function parseFreeText(raw: string): {
|
||||
query: queryParts.join(' '),
|
||||
exactPhrase,
|
||||
negatedWords: negatedWords.join(' '),
|
||||
fromMe,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,7 +220,7 @@ export function parseAdvancedSearch(
|
||||
* can be expressed as operators; exclude rows come solely from filter params.
|
||||
*/
|
||||
const lifted = extractSearchPostsParams(q)
|
||||
const freeText = parseFreeText(lifted.q)
|
||||
const {fromMe, ...freeText} = parseFreeText(lifted.q)
|
||||
|
||||
const includeValues: Record<FilterField, string> = {
|
||||
authors: mergeValues(filters.author, lifted.author),
|
||||
@@ -255,12 +267,24 @@ export function parseAdvancedSearch(
|
||||
const since = filters.since ?? lifted.since
|
||||
const until = filters.until ?? lifted.until
|
||||
|
||||
/*
|
||||
* A raw `from:me` operator and the structured `from=me` filter both map to
|
||||
* the "Me" author filter. The raw operator is promoted to the structured
|
||||
* filter when the dialog is submitted.
|
||||
*/
|
||||
let following: FromFilter = 'anyone'
|
||||
if (fromMe || filters.from === 'me') {
|
||||
following = 'me'
|
||||
} else if (filters.following === 'true') {
|
||||
following = 'following'
|
||||
}
|
||||
|
||||
return {
|
||||
...freeText,
|
||||
language: lang,
|
||||
replies,
|
||||
media,
|
||||
following: filters.following === 'true' ? 'following' : 'anyone',
|
||||
following,
|
||||
since: since && isValidDate(since) ? since : '',
|
||||
until: until && isValidDate(until) ? until : '',
|
||||
filters: filterRows,
|
||||
@@ -279,7 +303,7 @@ export function serializeAdvancedSearch(state: {
|
||||
language: string
|
||||
replies: RepliesFilter
|
||||
media: MediaFilter
|
||||
following: FollowingFilter
|
||||
following: FromFilter
|
||||
dateSince: string
|
||||
dateSinceActive: boolean
|
||||
dateUntil: string
|
||||
@@ -352,7 +376,21 @@ export function serializeAdvancedSearch(state: {
|
||||
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}
|
||||
/*
|
||||
* Re-parse the final text because a user can type `from:me` after the dialog
|
||||
* has opened. Advanced submission removes every bare token from the search
|
||||
* input and promotes it to the structured From filter. Quoted `"from:me"`
|
||||
* remains ordinary query text.
|
||||
*/
|
||||
const {q, fromMe} = extractFromMe(parts.join(' '))
|
||||
if (state.following === 'following') filters.following = 'true'
|
||||
else if (state.following === 'me' || fromMe) filters.from = 'me'
|
||||
|
||||
/*
|
||||
* Submitting the dialog promotes a raw `from:me` operator to `from=me`, so it
|
||||
* leaves the search text and is represented by the From dropdown. The query
|
||||
* hook reconstructs the backend operator at the API boundary.
|
||||
*/
|
||||
return {q, filters}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user