Add From: Me to advanced search filters (#11126)

This commit is contained in:
DS Boyce
2026-07-22 15:01:24 -07:00
committed by GitHub
parent 850765bc8d
commit 77bbe8d8a0
12 changed files with 249 additions and 44 deletions
@@ -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}
}