Migrate all search to v2 and remove v1 (#11125)

This commit is contained in:
DS Boyce
2026-07-09 15:08:20 -07:00
committed by GitHub
parent 65e10f13f7
commit 3499d3646e
14 changed files with 114 additions and 475 deletions
+25 -24
View File
@@ -1,9 +1,7 @@
import {useCallback, useMemo, useState} from 'react'
import {type ListRenderItemInfo, View} from 'react-native'
import {type AppBskyFeedDefs} from '@atproto/api'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {Trans, useLingui} from '@lingui/react/macro'
import {type NativeStackScreenProps} from '@react-navigation/native-stack'
import {HITSLOP_10} from '#/lib/constants'
@@ -14,7 +12,7 @@ import {shareUrl} from '#/lib/sharing'
import {cleanError} from '#/lib/strings/errors'
import {sanitizeHandle} from '#/lib/strings/handles'
import {enforceLen} from '#/lib/strings/helpers'
import {useSearchPostsQuery} from '#/state/queries/search-posts'
import {useSearchPostsV2Query} from '#/state/queries/search-posts-v2'
import {useSession} from '#/state/session'
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
import {useCloseAllActiveElements} from '#/state/util'
@@ -43,7 +41,7 @@ export default function HashtagScreen({
route,
}: NativeStackScreenProps<CommonNavigatorParams, 'Hashtag'>) {
const {tag, author} = route.params
const {_} = useLingui()
const {t: l} = useLingui()
const decodedTag = useMemo(() => {
return decodeURIComponent(tag)
@@ -73,7 +71,7 @@ export default function HashtagScreen({
if (author) {
url.searchParams.set('author', author)
}
shareUrl(url.toString())
void shareUrl(url.toString())
}, [tag, author])
const [activeTab, setActiveTab] = useState(0)
@@ -85,7 +83,7 @@ export default function HashtagScreen({
const sections = useMemo(() => {
return [
{
title: _(msg`Top`),
title: l`Top`,
component: (
<HashtagScreenTab
fullTag={fullTag}
@@ -96,7 +94,7 @@ export default function HashtagScreen({
),
},
{
title: _(msg`Latest`),
title: l`Latest`,
component: (
<HashtagScreenTab
fullTag={fullTag}
@@ -107,7 +105,7 @@ export default function HashtagScreen({
),
},
]
}, [_, fullTag, author, activeTab])
}, [l, fullTag, author, activeTab])
return (
<Layout.Screen>
@@ -122,14 +120,14 @@ export default function HashtagScreen({
{author && (
<Layout.Header.SubtitleText>
{author.startsWith('did:')
? _(msg`From ${sanitizedAuthor}`)
: _(msg`From @${sanitizedAuthor}`)}
? l`From ${sanitizedAuthor}`
: l`From @${sanitizedAuthor}`}
</Layout.Header.SubtitleText>
)}
</Layout.Header.Content>
<Layout.Header.Slot>
<Button
label={_(msg`Share`)}
label={l`Share`}
size="small"
variant="ghost"
color="primary"
@@ -164,7 +162,7 @@ function HashtagScreenTab({
sort: 'top' | 'latest'
active: boolean
}) {
const {_} = useLingui()
const {t: l} = useLingui()
const initialNumToRender = useInitialNumToRender()
const [isPTR, setIsPTR] = useState(false)
const t = useTheme()
@@ -188,7 +186,14 @@ function HashtagScreenTab({
refetch,
fetchNextPage,
hasNextPage,
} = useSearchPostsQuery({query: queryParam, sort, enabled: active, author})
} = useSearchPostsV2Query({
query: queryParam,
sort,
enabled: active,
filters: {
author,
},
})
const posts = useMemo(() => {
return data?.pages.flatMap(page => page.posts) || []
@@ -202,7 +207,7 @@ function HashtagScreenTab({
const onEndReached = useCallback(() => {
if (isFetchingNextPage || !hasNextPage || error) return
fetchNextPage()
void fetchNextPage()
}, [isFetchingNextPage, hasNextPage, error, fetchNextPage])
const closeAllActiveElements = useCloseAllActiveElements()
@@ -220,19 +225,15 @@ function HashtagScreenTab({
if (!hasSession) {
return (
<SearchError
title={_(msg`Search is currently unavailable when logged out`)}>
<SearchError title={l`Search is currently unavailable when logged out`}>
<Text style={[a.text_md, a.text_center, a.leading_snug]}>
<Trans>
<InlineLinkText
label={_(msg`Sign in`)}
to={'#'}
onPress={showSignIn}>
<InlineLinkText label={l`Sign in`} to={'#'} onPress={showSignIn}>
Sign in
</InlineLinkText>
<Text style={t.atoms.text_contrast_medium}> or </Text>
<InlineLinkText
label={_(msg`Create an account`)}
label={l`Create an account`}
to={'#'}
onPress={showCreateAccount}>
create an account
@@ -256,7 +257,7 @@ function HashtagScreenTab({
isError={isError}
onRetry={refetch}
emptyType="results"
emptyMessage={_(msg`We couldn't find any results for that tag.`)}
emptyMessage={l`We couldn't find any results for that tag.`}
/>
) : (
<List
@@ -264,7 +265,7 @@ function HashtagScreenTab({
renderItem={renderItem}
keyExtractor={keyExtractor}
refreshing={isPTR}
onRefresh={onRefresh}
onRefresh={() => void onRefresh()}
onEndReached={onEndReached}
onEndReachedThreshold={4}
onItemSeen={trackPostView}
+16 -67
View File
@@ -10,7 +10,6 @@ import {cleanError} from '#/lib/strings/errors'
import {augmentSearchQuery} from '#/lib/strings/helpers'
import {useActorSearch} from '#/state/queries/actor-search'
import {usePopularFeedsSearch} from '#/state/queries/feed'
import {useSearchPostsQuery} from '#/state/queries/search-posts'
import {useSearchPostsV2Query} from '#/state/queries/search-posts-v2'
import {useSession} from '#/state/session'
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
@@ -36,7 +35,6 @@ import type * as bsky from '#/types/bsky'
let SearchResults = ({
query,
queryWithParams,
filters,
hasFilters,
activeTab,
@@ -44,7 +42,6 @@ let SearchResults = ({
headerHeight,
}: {
query: string
queryWithParams: string
filters: SearchFilters
hasFilters: boolean
activeTab: number
@@ -75,7 +72,6 @@ let SearchResults = ({
<SearchScreenPostResults
hasFilters={hasFilters}
query={query}
queryWithParams={queryWithParams}
filters={filters}
sort="top"
active={activePage === 0}
@@ -88,7 +84,6 @@ let SearchResults = ({
<SearchScreenPostResults
hasFilters={hasFilters}
query={query}
queryWithParams={queryWithParams}
filters={filters}
sort="latest"
active={activePage === 1}
@@ -111,15 +106,7 @@ let SearchResults = ({
title: string
component: React.ReactNode
}[]
}, [
l,
query,
queryWithParams,
filters,
hasFilters,
hasPostFilters,
activePage,
])
}, [l, query, filters, hasFilters, hasPostFilters, activePage])
// There may be fewer tabs after changing the search options.
const selectedPage = activePage > sections.length - 1 ? 0 : activePage
@@ -204,14 +191,9 @@ function NoResultsText({
hasFilters?: boolean
query: string
}) {
const ax = useAnalytics()
const t = useTheme()
const {t: l} = useLingui()
const searchV2Enabled = ax.features.enabled(ax.features.SearchV2Enable)
const advancedSearchV2Enabled =
searchV2Enabled && ax.features.enabled(ax.features.AdvancedSearchV2Enable)
return (
<>
<Text style={[a.text_lg, t.atoms.text_contrast_high]}>
@@ -257,35 +239,19 @@ function NoResultsText({
a.leading_snug,
t.atoms.text_contrast_high,
]}>
{advancedSearchV2Enabled ? (
<Trans context="english-only-resource">
Learn more about{' '}
<InlineLinkText
label={l({
message: 'Read about how to use advanced search filters',
context: 'english-only-resource',
})}
to={urls.website.blog.searchTipsAndTricks}
style={[a.text_md, a.leading_snug]}>
how to use advanced search
</InlineLinkText>
.
</Trans>
) : (
<Trans context="english-only-resource">
Learn more about{' '}
<InlineLinkText
label={l({
message: 'Read about how to use search filters',
context: 'english-only-resource',
})}
to={urls.website.blog.searchTipsAndTricks}
style={[a.text_md, a.leading_snug]}>
how to use search filters
</InlineLinkText>
.
</Trans>
)}
<Trans context="english-only-resource">
Learn more about{' '}
<InlineLinkText
label={l({
message: 'Read about how to use advanced search filters',
context: 'english-only-resource',
})}
to={urls.website.blog.searchTipsAndTricks}
style={[a.text_md, a.leading_snug]}>
how to use advanced search
</InlineLinkText>
.
</Trans>
</Text>
</>
)
@@ -305,14 +271,12 @@ type SearchResultSlice =
let SearchScreenPostResults = ({
hasFilters = false,
query,
queryWithParams,
filters,
sort,
active,
}: {
hasFilters: boolean
query: string
queryWithParams: string
filters?: SearchFilters
sort?: 'top' | 'latest'
active: boolean
@@ -323,30 +287,15 @@ let SearchScreenPostResults = ({
const [isPTR, setIsPTR] = useState(false)
const trackPostView = usePostViewTracking('SearchResults')
const searchV2Enabled = ax.features.enabled(ax.features.SearchV2Enable)
const augmentedV2Query = useMemo(() => {
return augmentSearchQuery(query || '')
}, [query])
const augmentedV1Query = useMemo(() => {
return augmentSearchQuery(queryWithParams || '')
}, [queryWithParams])
/*
* Both hooks are called to keep hook order stable; `enabled` ensures only the
* gated one actually fetches. V2 sends structured `filters` as separate
* params, V1 keeps the existing single-`q` behavior.
*/
const v1 = useSearchPostsQuery({
query: augmentedV1Query,
sort,
enabled: active && !searchV2Enabled,
})
const v2 = useSearchPostsV2Query({
query: augmentedV2Query,
filters,
sort,
enabled: active && searchV2Enabled,
enabled: active,
})
const {
isFetched,
@@ -357,7 +306,7 @@ let SearchScreenPostResults = ({
fetchNextPage,
isFetchingNextPage,
hasNextPage,
} = searchV2Enabled ? v2 : v1
} = v2
const t = useTheme()
const onPullToRefresh = useCallback(async () => {
+7 -34
View File
@@ -36,7 +36,6 @@ import {useSession} from '#/state/session'
import {
countActiveFilters,
definedFilterParams,
filtersToLegacyParams,
filtersToRouteParams,
hasActiveFilters,
parseHistoryEntry,
@@ -45,7 +44,6 @@ import {
serializeHistoryEntry,
withoutFilterParams,
} from '#/screens/Search/searchParams'
import {makeSearchQuery} from '#/screens/Search/utils'
import {
atoms as a,
native,
@@ -71,7 +69,6 @@ import {AutocompleteResults} from './components/AutocompleteResults'
import {DetectedLanguagesAdmonition} from './components/DetectedLanguagesAdmonition'
import {SearchAutocompleteInput} from './components/SearchAutocompleteInput'
import {SearchHistory} from './components/SearchHistory'
import {SearchLanguageDropdown} from './components/SearchLanguageDropdown'
import {Explore} from './Explore'
import {SearchResults} from './SearchResults'
@@ -122,10 +119,6 @@ export function SearchScreenShell({
const {currentAccount} = useSession()
const queryClient = useQueryClient()
const searchV2Enabled = ax.features.enabled(ax.features.SearchV2Enable)
const advancedSearchV2Enabled =
searchV2Enabled && ax.features.enabled(ax.features.AdvancedSearchV2Enable)
// Get tab parameter from route params
const tabParam = (route.params as {q?: string; tab?: TabParam})?.tab
const [activeTab, setActiveTab] = useState(() => getTabIndex(tabParam))
@@ -207,11 +200,10 @@ export function SearchScreenShell({
[accountHistory, setAccountHistory],
)
const {query, queryWithParams, filters, setFilters, hasFilters} =
useQueryManager({
initialQuery: queryParam,
fixedParams,
})
const {query, filters, setFilters, hasFilters} = useQueryManager({
initialQuery: queryParam,
fixedParams,
})
const showFilters = Boolean((query || hasFilters) && !showAutocomplete)
const onChangeLang = useCallback(
@@ -560,12 +552,7 @@ export function SearchScreenShell({
{isExplore ? <Trans>Explore</Trans> : <Trans>Search</Trans>}
</Layout.Header.TitleText>
</Layout.Header.Content>
{showFilters && !advancedSearchV2Enabled ? (
<SearchLanguageDropdown
value={filters.lang ?? ''}
onChange={onChangeLang}
/>
) : showFilters && advancedSearchV2Enabled ? (
{showFilters ? (
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
<AdvancedSearchDialog
disabled={activeTab > 1}
@@ -591,7 +578,7 @@ export function SearchScreenShell({
)}
<View style={[a.px_lg, a.pt_sm, a.pb_sm, a.overflow_hidden]}>
<View style={[a.gap_sm]}>
{searchV2Enabled && query && !showAutocomplete && (
{query && !showAutocomplete && (
<DetectedLanguagesAdmonition
query={query}
filters={filters}
@@ -635,7 +622,7 @@ export function SearchScreenShell({
/>
</View>
</View>
{showFilters && !showHeader && advancedSearchV2Enabled ? (
{showFilters && !showHeader ? (
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
<AdvancedSearchDialog
disabled={activeTab > 1}
@@ -655,15 +642,6 @@ export function SearchScreenShell({
</View>
) : null}
</View>
{showFilters && !showHeader && !advancedSearchV2Enabled && (
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
<SearchLanguageDropdown
value={filters.lang ?? ''}
onChange={onChangeLang}
/>
</View>
)}
</View>
</View>
</Layout.Center>
@@ -676,7 +654,6 @@ export function SearchScreenShell({
activeTab={activeTab}
setActiveTab={setActiveTab}
query={query}
queryWithParams={queryWithParams}
filters={filters}
hasFilters={hasFilters}
headerHeight={headerHeight}
@@ -728,7 +705,6 @@ let SearchScreenInner = ({
activeTab,
setActiveTab,
query,
queryWithParams,
filters,
hasFilters,
headerHeight,
@@ -737,7 +713,6 @@ let SearchScreenInner = ({
activeTab: number
setActiveTab: React.Dispatch<React.SetStateAction<number>>
query: string
queryWithParams: string
filters: SearchFilters
hasFilters: boolean
headerHeight: number
@@ -754,7 +729,6 @@ let SearchScreenInner = ({
return query || hasFilters ? (
<SearchResults
query={query}
queryWithParams={queryWithParams}
filters={filters}
hasFilters={hasFilters}
activeTab={activeTab}
@@ -840,7 +814,6 @@ function useQueryManager({
return useMemo(
() => ({
query,
queryWithParams: makeSearchQuery(query, filtersToLegacyParams(filters)),
filters,
setFilters,
hasFilters: hasActiveFilters(filters),
@@ -4,7 +4,6 @@ import {
countActiveFilters,
definedFilterParams,
filtersToApiParams,
filtersToLegacyParams,
hasPostOnlyFilters,
parseHistoryEntry,
readSearchFilters,
@@ -76,34 +75,6 @@ describe(`searchParams`, () => {
})
})
describe(`filtersToLegacyParams`, () => {
it(`maps structured filters back to legacy query operators`, () => {
expect(
filtersToLegacyParams({
author: 'alice',
mentions: 'bob',
domain: 'bsky.app',
url: 'bsky.app/x',
tag: 'atproto',
lang: 'en',
since: '2024-01-01',
until: '2024-02-01',
media: 'true',
replies: 'none',
}),
).toEqual({
from: 'alice',
mentions: 'bob',
domain: 'bsky.app',
url: 'bsky.app/x',
tag: 'atproto',
lang: 'en',
since: '2024-01-01',
until: '2024-02-01',
})
})
})
describe(`filtersToApiParams`, () => {
it(`splits list fields into arrays and maps v2-only filters`, () => {
expect(
@@ -1,12 +1,10 @@
import {useMemo} from 'react'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {Trans, useLingui} from '@lingui/react/macro'
import {languageName} from '#/locale/helpers'
import {APP_LANGUAGES, LANGUAGES} from '#/locale/languages'
import {useLanguagePrefs} from '#/state/preferences'
import {atoms as a, native, platform, tokens} from '#/alf'
import {atoms as a, native, platform} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {
ChevronBottom_Stroke2_Corner0_Rounded as ChevronDownIcon,
@@ -14,7 +12,6 @@ import {
} from '#/components/icons/Chevron'
import {Earth_Stroke2_Corner0_Rounded as EarthIcon} from '#/components/icons/Globe'
import * as Menu from '#/components/Menu'
import {useAnalytics} from '#/analytics'
export function SearchLanguageDropdown({
showIcon = true,
@@ -25,14 +22,9 @@ export function SearchLanguageDropdown({
value: string
onChange(value: string): void
}) {
const ax = useAnalytics()
const {_} = useLingui()
const {t: l} = useLingui()
const {appLanguage, contentLanguages, primaryLanguage} = useLanguagePrefs()
const searchV2Enabled = ax.features.enabled(ax.features.SearchV2Enable)
const advancedSearchV2Enabled =
searchV2Enabled && ax.features.enabled(ax.features.AdvancedSearchV2Enable)
const languages = useMemo(() => {
return LANGUAGES.filter(
(lang, index, self) =>
@@ -70,40 +62,20 @@ export function SearchLanguageDropdown({
}, [appLanguage, contentLanguages, primaryLanguage])
const currentLanguageLabel =
languages.find(lang => lang.value === value)?.label ?? _(msg`All languages`)
languages.find(lang => lang.value === value)?.label ?? l`All languages`
return (
<Menu.Root>
<Menu.Trigger
label={_(
msg`Filter search by language (currently: ${currentLanguageLabel})`,
)}>
label={l`Filter search by language (currently: ${currentLanguageLabel})`}>
{({props}) => (
<Button
{...props}
label={props.accessibilityLabel}
size="small"
color={
advancedSearchV2Enabled
? 'secondary'
: platform({native: 'primary', default: 'secondary'})
}
variant={
advancedSearchV2Enabled
? 'solid'
: platform({native: 'ghost', default: 'solid'})
}
style={
showIcon
? native([
a.py_sm,
a.px_sm,
advancedSearchV2Enabled
? null
: {marginRight: tokens.space.sm * -1},
])
: null
}>
color="secondary"
variant="solid"
style={showIcon ? native([a.py_sm, a.px_sm]) : null}>
{showIcon ? <ButtonIcon icon={EarthIcon} /> : null}
<ButtonText>{currentLanguageLabel}</ButtonText>
<ButtonIcon
@@ -119,7 +91,7 @@ export function SearchLanguageDropdown({
<Menu.LabelText>
<Trans>Filter search by language</Trans>
</Menu.LabelText>
<Menu.Item label={_(msg`All languages`)} onPress={() => onChange('')}>
<Menu.Item label={l`All languages`} onPress={() => onChange('')}>
<Menu.ItemText>
<Trans>All languages</Trans>
</Menu.ItemText>
-21
View File
@@ -188,27 +188,6 @@ export function withoutFilterParams(
}
return base
}
/**
* Converts structured filters back into the legacy free-text operators used by
* search v1. UI-only filters are intentionally dropped because the old path
* could not apply them.
*/
export function filtersToLegacyParams(
filters: SearchFilters,
): Record<string, string> {
const params: Record<string, string> = {}
if (filters.author) params.from = filters.author
if (filters.mentions) params.mentions = filters.mentions
if (filters.domain) params.domain = filters.domain
if (filters.url) params.url = filters.url
if (filters.tag) params.tag = filters.tag
if (filters.lang) params.lang = filters.lang
if (filters.since) params.since = filters.since
if (filters.until) params.until = filters.until
return params
}
/**
* Maps each multi-value SearchFilters key to its `app.bsky.feed.searchPostsV2`
* param name. Search v1 only honored the first value for the singular lexicon
-12
View File
@@ -30,15 +30,3 @@ export function parseSearchQuery(rawQuery: string) {
params,
}
}
export function makeSearchQuery(query: string, params: Params) {
return [
query,
Object.entries(params)
.filter(([_, value]) => value)
.map(([name, value]) => `${name}:${value}`)
.join(' '),
]
.filter(Boolean)
.join(' ')
}
+13 -14
View File
@@ -1,8 +1,7 @@
import {useCallback, useMemo, useState} from 'react'
import {type ListRenderItemInfo, View} from 'react-native'
import {type AppBskyFeedDefs} from '@atproto/api'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {useLingui} from '@lingui/react/macro'
import {type NativeStackScreenProps} from '@react-navigation/native-stack'
import {HITSLOP_10} from '#/lib/constants'
@@ -12,7 +11,7 @@ import {type CommonNavigatorParams} from '#/lib/routes/types'
import {shareUrl} from '#/lib/sharing'
import {cleanError} from '#/lib/strings/errors'
import {enforceLen} from '#/lib/strings/helpers'
import {useSearchPostsQuery} from '#/state/queries/search-posts'
import {useSearchPostsV2Query} from '#/state/queries/search-posts-v2'
import {Pager} from '#/view/com/pager/Pager'
import {TabBar} from '#/view/com/pager/TabBar'
import {Post} from '#/view/com/post/Post'
@@ -35,7 +34,7 @@ export default function TopicScreen({
route,
}: NativeStackScreenProps<CommonNavigatorParams, 'Topic'>) {
const {topic} = route.params
const {_} = useLingui()
const {t: l} = useLingui()
const headerTitle = useMemo(() => {
return enforceLen(decodeURIComponent(topic), 24, true, 'middle')
@@ -44,7 +43,7 @@ export default function TopicScreen({
const onShare = useCallback(() => {
const url = new URL('https://bsky.app')
url.pathname = `/topic/${topic}`
shareUrl(url.toString())
void shareUrl(url.toString())
}, [topic])
const [activeTab, setActiveTab] = useState(0)
@@ -56,13 +55,13 @@ export default function TopicScreen({
const sections = useMemo(() => {
return [
{
title: _(msg`Top`),
title: l`Top`,
component: (
<TopicScreenTab topic={topic} sort="top" active={activeTab === 0} />
),
},
{
title: _(msg`Latest`),
title: l`Latest`,
component: (
<TopicScreenTab
topic={topic}
@@ -72,7 +71,7 @@ export default function TopicScreen({
),
},
]
}, [_, topic, activeTab])
}, [l, topic, activeTab])
return (
<Layout.Screen>
@@ -87,7 +86,7 @@ export default function TopicScreen({
</Layout.Header.Content>
<Layout.Header.Slot>
<Button
label={_(msg`Share`)}
label={l`Share`}
size="small"
variant="ghost"
color="primary"
@@ -120,7 +119,7 @@ function TopicScreenTab({
sort: 'top' | 'latest'
active: boolean
}) {
const {_} = useLingui()
const {t: l} = useLingui()
const initialNumToRender = useInitialNumToRender()
const [isPTR, setIsPTR] = useState(false)
const trackPostView = usePostViewTracking('Topic')
@@ -135,7 +134,7 @@ function TopicScreenTab({
refetch,
fetchNextPage,
hasNextPage,
} = useSearchPostsQuery({
} = useSearchPostsV2Query({
query: decodeURIComponent(topic),
sort,
enabled: active,
@@ -153,7 +152,7 @@ function TopicScreenTab({
const onEndReached = useCallback(() => {
if (isFetchingNextPage || !hasNextPage || error) return
fetchNextPage()
void fetchNextPage()
}, [isFetchingNextPage, hasNextPage, error, fetchNextPage])
return (
@@ -164,7 +163,7 @@ function TopicScreenTab({
isError={isError}
onRetry={refetch}
emptyType="results"
emptyMessage={_(msg`We couldn't find any results for that topic.`)}
emptyMessage={l`We couldn't find any results for that topic.`}
/>
) : (
<List
@@ -172,7 +171,7 @@ function TopicScreenTab({
renderItem={renderItem}
keyExtractor={keyExtractor}
refreshing={isPTR}
onRefresh={onRefresh}
onRefresh={() => void onRefresh()}
onEndReached={onEndReached}
onEndReachedThreshold={4}
onItemSeen={trackPostView}