[Explore] Base (#8053)

* migrate to #/screens

* rm unneeded import

* block drawer gesture on recent profiles

* rm recommendations (#8056)

* [Explore] Disable Trending videos (#8054)

* remove giant header

* disable

* [Explore] Dynamic module ordering (#8066)

* Dynamic module ordering

* [Explore] New headers, metrics (#8067)

* new sticky headers

* improve spacing between modules

* view metric on modules

* update metrics names

* [Explore] Suggested accounts module (#8072)

* use modern profile card, update load more

* add tab bar

* tabbed suggested accounts

* [Explore] Discover feeds module (#8073)

* cap number of feeds to 3

* change feed pin button

* Apply suggestions from code review

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* restore statsig to log events

* filter out followed profiles, make suer enough are loaded (#8090)

* [Explore] Trending topics (#8055)

* redesigned trending topics

* rm borders on web

* get post count / age / ranking from api

* spacing tweaks

* fetch more topics then slice

* use api data for avis/category

* rm top border

* Integrate new SDK, part out components

* Clean up

* Use status field

* Bump SDK

* Send up interests and langs

---------

Co-authored-by: Eric Bailey <git@esb.lol>

* Clean up module spacing and borders

(cherry picked from commit 63d19b6c2d67e226e0e14709b1047a1f88b3ce1c)
(cherry picked from commit 62d7d394ab1dc31b40b9c2cf59075adbf94737a1)

* Switch back border ordering

(cherry picked from commit 34e3789f8b410132c1390df3c2bb8257630ebdd9)

* [Explore] Starter Packs (#8095)

* Temp WIP

(cherry picked from commit 43b5d7b1e64b3adb1ed162262d0310e0bf026c18)

* New SP card

* Load state

* Revert change

* Cleanup

* Interests and caching

* Count total

* Format

* Caching

* [Explore] Feed previews module (#8075)

* wip new hook

* get fetching working, maybe

* get feed previews rendering!

* fix header height

* working pin button

* extract out FeedLink

* add loader

* only make preview:header sticky

* Fix headers

* Header tweaks

* Fix moderation filter

* Fix threading

---------

Co-authored-by: Eric Bailey <git@esb.lol>

* Space it out

* Fix query key

* Mock new endpoint, filter saved feeds

* Make sure we're pinning, lower cache time

* add news category

* Remove log

* Improve suggested accounts load state

* Integrate new app view endpoint

* fragment

* Update src/screens/Search/modules/ExploreTrendingTopics.tsx

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Update src/screens/Search/modules/ExploreTrendingTopics.tsx

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* lint

* maybe fix this

---------

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>
Co-authored-by: Eric Bailey <git@esb.lol>
Co-authored-by: Hailey <me@haileyok.com>
This commit is contained in:
Samuel Newman
2025-04-03 03:21:15 +03:00
committed by GitHub
parent 8d1f97b5ff
commit 87da619aaa
45 changed files with 3834 additions and 2178 deletions
+923
View File
@@ -0,0 +1,923 @@
import {useCallback, useMemo, useRef, useState} from 'react'
import {View, type ViewabilityConfig, type ViewToken} from 'react-native'
import {
type AppBskyActorDefs,
type AppBskyFeedDefs,
type AppBskyGraphDefs,
} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useGate} from '#/lib/statsig/statsig'
import {cleanError} from '#/lib/strings/errors'
import {sanitizeHandle} from '#/lib/strings/handles'
import {logger} from '#/logger'
import {type MetricEvents} from '#/logger/metrics'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useActorSearchPaginated} from '#/state/queries/actor-search'
import {useGetPopularFeedsQuery} from '#/state/queries/feed'
import {usePreferencesQuery} from '#/state/queries/preferences'
import {useSuggestedFollowsQuery} from '#/state/queries/suggested-follows'
import {useGetSuggestedFeedsQuery} from '#/state/queries/trending/useGetSuggestedFeedsQuery'
import {useSuggestedStarterPacksQuery} from '#/state/queries/useSuggestedStarterPacksQuery'
import {useProgressGuide} from '#/state/shell/progress-guide'
import {isThreadChildAt, isThreadParentAt} from '#/view/com/posts/PostFeed'
import {PostFeedItem} from '#/view/com/posts/PostFeedItem'
import {ViewFullThread} from '#/view/com/posts/ViewFullThread'
import {List} from '#/view/com/util/List'
import {FeedFeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
import {LoadMoreRetryBtn} from '#/view/com/util/LoadMoreRetryBtn'
import {
StarterPackCard,
StarterPackCardSkeleton,
} from '#/screens/Search/components/StarterPackCard'
import {ExploreRecommendations} from '#/screens/Search/modules/ExploreRecommendations'
import {ExploreTrendingTopics} from '#/screens/Search/modules/ExploreTrendingTopics'
import {ExploreTrendingVideos} from '#/screens/Search/modules/ExploreTrendingVideos'
import {atoms as a, native, useTheme, web} from '#/alf'
import {Button} from '#/components/Button'
import * as FeedCard from '#/components/FeedCard'
import {ChevronBottom_Stroke2_Corner0_Rounded as ChevronDownIcon} from '#/components/icons/Chevron'
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
import {type Props as SVGIconProps} from '#/components/icons/common'
import {ListSparkle_Stroke2_Corner0_Rounded as ListSparkle} from '#/components/icons/ListSparkle'
import {StarterPack} from '#/components/icons/StarterPack'
import {UserCircle_Stroke2_Corner0_Rounded as Person} from '#/components/icons/UserCircle'
import {Loader} from '#/components/Loader'
import * as ProfileCard from '#/components/ProfileCard'
import {Text} from '#/components/Typography'
import * as ModuleHeader from './components/ModuleHeader'
import {
type FeedPreviewItem,
useFeedPreviews,
} from './modules/ExploreFeedPreviews'
import {
SuggestedAccountsTabBar,
SuggestedProfileCard,
useLoadEnoughProfiles,
} from './modules/ExploreSuggestedAccounts'
function LoadMore({item}: {item: ExploreScreenItems & {type: 'loadMore'}}) {
const t = useTheme()
const {_} = useLingui()
return (
<Button
label={_(msg`Load more`)}
onPress={item.onLoadMore}
style={[a.relative, a.w_full]}>
{({hovered, pressed}) => (
<View
style={[
a.flex_1,
a.flex_row,
a.align_center,
a.justify_center,
a.px_lg,
a.py_md,
a.gap_sm,
(hovered || pressed) && t.atoms.bg_contrast_25,
]}>
<Text
style={[
a.leading_snug,
hovered ? t.atoms.text : t.atoms.text_contrast_medium,
]}>
{item.message}
</Text>
{item.isLoadingMore ? (
<Loader size="sm" />
) : (
<ChevronDownIcon
size="sm"
style={hovered ? t.atoms.text : t.atoms.text_contrast_medium}
/>
)}
</View>
)}
</Button>
)
}
type ExploreScreenItems =
| {
type: 'topBorder'
key: string
}
| {
type: 'header'
key: string
title: string
icon: React.ComponentType<SVGIconProps>
searchButton?: {
label: string
metricsTag: MetricEvents['explore:module:searchButtonPress']['module']
tab: 'user' | 'profile' | 'feed'
}
}
| {
type: 'tabbedHeader'
key: string
title: string
icon: React.ComponentType<SVGIconProps>
searchButton?: {
label: string
metricsTag: MetricEvents['explore:module:searchButtonPress']['module']
tab: 'user' | 'profile' | 'feed'
}
}
| {
type: 'trendingTopics'
key: string
}
| {
type: 'trendingVideos'
key: string
}
| {
type: 'recommendations'
key: string
}
| {
type: 'profile'
key: string
profile: AppBskyActorDefs.ProfileView
recId?: number
}
| {
type: 'feed'
key: string
feed: AppBskyFeedDefs.GeneratorView
}
| {
type: 'loadMore'
key: string
message: string
isLoadingMore: boolean
onLoadMore: () => void
}
| {
type: 'profilePlaceholder'
key: string
}
| {
type: 'feedPlaceholder'
key: string
}
| {
type: 'error'
key: string
message: string
error: string
}
| {
type: 'starterPack'
key: string
view: AppBskyGraphDefs.StarterPackView
}
| {
type: 'starterPackSkeleton'
key: string
}
| FeedPreviewItem
export function Explore({
focusSearchInput,
headerHeight,
}: {
focusSearchInput: (tab: 'user' | 'profile' | 'feed') => void
headerHeight: number
}) {
const {_} = useLingui()
const t = useTheme()
const {data: preferences, error: preferencesError} = usePreferencesQuery()
const moderationOpts = useModerationOpts()
const gate = useGate()
const guide = useProgressGuide('follow-10')
const [selectedInterest, setSelectedInterest] = useState<string | null>(null)
const {
data: suggestedProfiles,
hasNextPage: hasNextSuggestedProfilesPage,
isLoading: isLoadingSuggestedProfiles,
isFetchingNextPage: isFetchingNextSuggestedProfilesPage,
error: suggestedProfilesError,
fetchNextPage: fetchNextSuggestedProfilesPage,
} = useSuggestedFollowsQuery({limit: 3, subsequentPageLimit: 10})
const {
data: interestProfiles,
hasNextPage: hasNextInterestProfilesPage,
isLoading: isLoadingInterestProfiles,
isFetchingNextPage: isFetchingNextInterestProfilesPage,
error: interestProfilesError,
fetchNextPage: fetchNextInterestProfilesPage,
} = useActorSearchPaginated({
query: selectedInterest || '',
enabled: !!selectedInterest,
limit: 10,
})
const {isReady: canShowSuggestedProfiles} = useLoadEnoughProfiles({
interest: selectedInterest,
data: interestProfiles,
isLoading: isLoadingInterestProfiles,
isFetchingNextPage: isFetchingNextInterestProfilesPage,
hasNextPage: hasNextInterestProfilesPage,
fetchNextPage: fetchNextInterestProfilesPage,
})
const {
data: feeds,
hasNextPage: hasNextFeedsPage,
isLoading: isLoadingFeeds,
isFetchingNextPage: isFetchingNextFeedsPage,
error: feedsError,
fetchNextPage: fetchNextFeedsPage,
} = useGetPopularFeedsQuery({limit: 10})
const profiles: typeof suggestedProfiles & typeof interestProfiles =
!selectedInterest ? suggestedProfiles : interestProfiles
const hasNextProfilesPage = !selectedInterest
? hasNextSuggestedProfilesPage
: hasNextInterestProfilesPage
const isLoadingProfiles = !selectedInterest
? isLoadingSuggestedProfiles
: !canShowSuggestedProfiles
const isFetchingNextProfilesPage = !selectedInterest
? isFetchingNextSuggestedProfilesPage
: !canShowSuggestedProfiles
const profilesError = !selectedInterest
? suggestedProfilesError
: interestProfilesError
const fetchNextProfilesPage = !selectedInterest
? fetchNextSuggestedProfilesPage
: fetchNextInterestProfilesPage
const isLoadingMoreProfiles = isFetchingNextProfilesPage && !isLoadingProfiles
const onLoadMoreProfiles = useCallback(async () => {
if (isFetchingNextProfilesPage || !hasNextProfilesPage || profilesError)
return
try {
await fetchNextProfilesPage()
} catch (err) {
logger.error('Failed to load more suggested follows', {message: err})
}
}, [
isFetchingNextProfilesPage,
hasNextProfilesPage,
profilesError,
fetchNextProfilesPage,
])
const {
data: suggestedSPs,
isLoading: isLoadingSuggestedSPs,
error: suggestedSPsError,
} = useSuggestedStarterPacksQuery()
const isLoadingMoreFeeds = isFetchingNextFeedsPage && !isLoadingFeeds
const [hasPressedLoadMoreFeeds, setHasPressedLoadMoreFeeds] = useState(false)
const onLoadMoreFeeds = useCallback(async () => {
if (isFetchingNextFeedsPage || !hasNextFeedsPage || feedsError) return
if (!hasPressedLoadMoreFeeds) {
setHasPressedLoadMoreFeeds(true)
return
}
try {
await fetchNextFeedsPage()
} catch (err) {
logger.error('Failed to load more suggested follows', {message: err})
}
}, [
isFetchingNextFeedsPage,
hasNextFeedsPage,
feedsError,
fetchNextFeedsPage,
hasPressedLoadMoreFeeds,
])
const {data: suggestedFeeds} = useGetSuggestedFeedsQuery()
const {
data: feedPreviewSlices,
query: {
isPending: isPendingFeedPreviews,
isFetchingNextPage: isFetchingNextPageFeedPreviews,
fetchNextPage: fetchNextPageFeedPreviews,
hasNextPage: hasNextPageFeedPreviews,
error: feedPreviewSlicesError,
},
} = useFeedPreviews(suggestedFeeds?.feeds ?? [])
const onLoadMoreFeedPreviews = useCallback(async () => {
if (
isPendingFeedPreviews ||
isFetchingNextPageFeedPreviews ||
!hasNextPageFeedPreviews ||
feedPreviewSlicesError
)
return
try {
await fetchNextPageFeedPreviews()
} catch (err) {
logger.error('Failed to load more feed previews', {message: err})
}
}, [
isPendingFeedPreviews,
isFetchingNextPageFeedPreviews,
hasNextPageFeedPreviews,
feedPreviewSlicesError,
fetchNextPageFeedPreviews,
])
const items = useMemo<ExploreScreenItems[]>(() => {
const i: ExploreScreenItems[] = []
const addTopBorder = () => {
i.push({type: 'topBorder', key: 'top-border'})
}
const addTrendingTopicsModule = () => {
i.push({
type: 'trendingTopics',
key: `trending-topics`,
})
// temp - disable trending videos
// if (isNative) {
// i.push({
// type: 'trendingVideos',
// key: `trending-videos`,
// })
// }
}
const addSuggestedFollowsModule = () => {
i.push({
type: 'tabbedHeader',
key: 'suggested-accounts-header',
title: _(msg`Suggested Accounts`),
icon: Person,
searchButton: {
label: _(msg`Search for more accounts`),
metricsTag: 'suggestedAccounts',
tab: 'user',
},
})
if (!canShowSuggestedProfiles) {
i.push({type: 'profilePlaceholder', key: 'profilePlaceholder'})
} else if (profilesError) {
i.push({
type: 'error',
key: 'profilesError',
message: _(msg`Failed to load suggested follows`),
error: cleanError(profilesError),
})
} else {
if (profiles !== undefined) {
if (profiles.pages.length > 0 && moderationOpts) {
// Currently the responses contain duplicate items.
// Needs to be fixed on backend, but let's dedupe to be safe.
let seen = new Set()
const profileItems: ExploreScreenItems[] = []
for (const page of profiles.pages) {
for (const actor of page.actors) {
if (!seen.has(actor.did) && !actor.viewer?.following) {
seen.add(actor.did)
profileItems.push({
type: 'profile',
key: actor.did,
profile: actor,
recId: page.recId,
})
}
}
}
if (profileItems.length === 0) {
if (!hasNextProfilesPage) {
// no items! remove the header
i.pop()
}
} else {
i.push(...profileItems)
}
if (hasNextProfilesPage) {
i.push({
type: 'loadMore',
key: 'loadMoreProfiles',
message: _(msg`Load more suggested accounts`),
isLoadingMore: isLoadingMoreProfiles,
onLoadMore: onLoadMoreProfiles,
})
}
} else {
console.log('no pages')
}
} else {
i.push({type: 'profilePlaceholder', key: 'profilePlaceholder'})
}
}
}
const addSuggestedFeedsModule = () => {
i.push({
type: 'header',
key: 'suggested-feeds-header',
title: _(msg`Discover Feeds`),
icon: ListSparkle,
searchButton: {
label: _(msg`Search for more feeds`),
metricsTag: 'suggestedFeeds',
tab: 'feed',
},
})
if (feeds && preferences) {
// Currently the responses contain duplicate items.
// Needs to be fixed on backend, but let's dedupe to be safe.
let seen = new Set()
const feedItems: ExploreScreenItems[] = []
for (const page of feeds.pages) {
for (const feed of page.feeds) {
if (!seen.has(feed.uri)) {
seen.add(feed.uri)
feedItems.push({
type: 'feed',
key: feed.uri,
feed,
})
}
}
}
// feeds errors can occur during pagination, so feeds is truthy
if (feedsError) {
i.push({
type: 'error',
key: 'feedsError',
message: _(msg`Failed to load suggested feeds`),
error: cleanError(feedsError),
})
} else if (preferencesError) {
i.push({
type: 'error',
key: 'preferencesError',
message: _(msg`Failed to load feeds preferences`),
error: cleanError(preferencesError),
})
} else {
if (feedItems.length === 0) {
if (!hasNextFeedsPage) {
i.pop()
}
} else {
// This query doesn't follow the limit very well, so the first press of the
// load more button just unslices the array back to ~10 items
if (!hasPressedLoadMoreFeeds) {
i.push(...feedItems.slice(0, 3))
} else {
i.push(...feedItems)
}
}
if (hasNextFeedsPage) {
i.push({
type: 'loadMore',
key: 'loadMoreFeeds',
message: _(msg`Load more suggested feeds`),
isLoadingMore: isLoadingMoreFeeds,
onLoadMore: onLoadMoreFeeds,
})
}
}
} else {
if (feedsError) {
i.push({
type: 'error',
key: 'feedsError',
message: _(msg`Failed to load suggested feeds`),
error: cleanError(feedsError),
})
} else if (preferencesError) {
i.push({
type: 'error',
key: 'preferencesError',
message: _(msg`Failed to load feeds preferences`),
error: cleanError(preferencesError),
})
} else {
i.push({type: 'feedPlaceholder', key: 'feedPlaceholder'})
}
}
}
const addSuggestedStarterPacksModule = () => {
i.push({
type: 'header',
key: 'suggested-starterPacks-header',
title: _(msg`Starter Packs`),
icon: StarterPack,
})
if (isLoadingSuggestedSPs) {
Array.from({length: 3}).forEach((_, index) =>
i.push({
type: 'starterPackSkeleton',
key: `starterPackSkeleton-${index}`,
}),
)
} else if (suggestedSPsError || !suggestedSPs) {
// just get rid of the section
i.pop()
} else {
suggestedSPs.starterPacks.map(s => {
i.push({
type: 'starterPack',
key: s.uri,
view: s,
})
})
}
}
const addFeedPreviews = () => {
i.push(...feedPreviewSlices)
if (isFetchingNextPageFeedPreviews) {
i.push({
type: 'preview:loading',
key: 'preview-loading-more',
})
}
}
// Dynamic module ordering
addTopBorder()
if (guide?.guide === 'follow-10' && !guide.isComplete) {
addSuggestedFollowsModule()
addSuggestedStarterPacksModule()
addTrendingTopicsModule()
} else {
addTrendingTopicsModule()
addSuggestedFollowsModule()
addSuggestedStarterPacksModule()
}
if (gate('explore_show_suggested_feeds')) {
addSuggestedFeedsModule()
}
addFeedPreviews()
return i
}, [
_,
profiles,
feeds,
preferences,
onLoadMoreFeeds,
onLoadMoreProfiles,
isLoadingMoreProfiles,
isLoadingMoreFeeds,
profilesError,
feedsError,
preferencesError,
hasNextProfilesPage,
hasNextFeedsPage,
guide,
gate,
moderationOpts,
hasPressedLoadMoreFeeds,
suggestedSPs,
isLoadingSuggestedSPs,
suggestedSPsError,
feedPreviewSlices,
isFetchingNextPageFeedPreviews,
canShowSuggestedProfiles,
])
const renderItem = useCallback(
({item, index}: {item: ExploreScreenItems; index: number}) => {
switch (item.type) {
case 'topBorder':
return (
<View
style={[
a.w_full,
t.atoms.border_contrast_low,
a.border_t,
headerHeight &&
web({
position: 'sticky',
top: headerHeight,
}),
]}
/>
)
case 'header': {
return (
<ModuleHeader.Container>
<ModuleHeader.Icon icon={item.icon} />
<ModuleHeader.TitleText>{item.title}</ModuleHeader.TitleText>
{item.searchButton && (
<ModuleHeader.SearchButton
{...item.searchButton}
onPress={() =>
focusSearchInput(item.searchButton?.tab || 'user')
}
/>
)}
</ModuleHeader.Container>
)
}
case 'tabbedHeader': {
return (
<View style={[a.pb_md]}>
<ModuleHeader.Container style={[a.pb_xs]}>
<ModuleHeader.Icon icon={item.icon} />
<ModuleHeader.TitleText>{item.title}</ModuleHeader.TitleText>
{item.searchButton && (
<ModuleHeader.SearchButton
{...item.searchButton}
onPress={() =>
focusSearchInput(item.searchButton?.tab || 'user')
}
/>
)}
</ModuleHeader.Container>
<SuggestedAccountsTabBar
selectedInterest={selectedInterest}
onSelectInterest={setSelectedInterest}
/>
</View>
)
}
case 'trendingTopics': {
return (
<View style={[a.pb_md]}>
<ExploreTrendingTopics />
</View>
)
}
case 'trendingVideos': {
return <ExploreTrendingVideos />
}
case 'recommendations': {
return <ExploreRecommendations />
}
case 'profile': {
return (
<SuggestedProfileCard
profile={item.profile}
moderationOpts={moderationOpts!}
recId={item.recId}
position={index}
/>
)
}
case 'feed': {
return (
<View
style={[
a.border_t,
t.atoms.border_contrast_low,
a.px_lg,
a.py_lg,
]}>
<FeedCard.Default view={item.feed} />
</View>
)
}
case 'starterPack': {
return (
<View style={[a.px_lg, a.pb_lg]}>
<StarterPackCard view={item.view} />
</View>
)
}
case 'starterPackSkeleton': {
return (
<View style={[a.px_lg, a.pb_lg]}>
<StarterPackCardSkeleton />
</View>
)
}
case 'loadMore': {
return (
<View style={[a.border_t, t.atoms.border_contrast_low]}>
<LoadMore item={item} />
</View>
)
}
case 'profilePlaceholder': {
return (
<>
{Array.from({length: 3}).map((_, index) => (
<View
style={[
a.px_lg,
a.py_lg,
a.border_t,
t.atoms.border_contrast_low,
]}
key={index}>
<ProfileCard.Outer>
<ProfileCard.Header>
<ProfileCard.AvatarPlaceholder />
<ProfileCard.NameAndHandlePlaceholder />
</ProfileCard.Header>
<ProfileCard.DescriptionPlaceholder numberOfLines={2} />
</ProfileCard.Outer>
</View>
))}
</>
)
}
case 'feedPlaceholder': {
return <FeedFeedLoadingPlaceholder />
}
case 'error':
case 'preview:error': {
return (
<View
style={[
a.border_t,
a.pt_md,
a.px_md,
t.atoms.border_contrast_low,
]}>
<View
style={[
a.flex_row,
a.gap_md,
a.p_lg,
a.rounded_sm,
t.atoms.bg_contrast_25,
]}>
<CircleInfo size="md" fill={t.palette.negative_400} />
<View style={[a.flex_1, a.gap_sm]}>
<Text style={[a.font_bold, a.leading_snug]}>
{item.message}
</Text>
<Text
style={[
a.italic,
a.leading_snug,
t.atoms.text_contrast_medium,
]}>
{item.error}
</Text>
</View>
</View>
</View>
)
}
// feed previews
case 'preview:empty': {
return null // what should we do here?
}
case 'preview:loading': {
return (
<View style={[a.py_2xl, a.flex_1, a.align_center]}>
<Loader size="lg" />
</View>
)
}
case 'preview:header': {
return (
<ModuleHeader.Container
headerHeight={headerHeight}
style={[a.pt_xs, a.border_b, t.atoms.border_contrast_low]}>
<ModuleHeader.FeedLink feed={item.feed}>
<ModuleHeader.FeedAvatar feed={item.feed} />
<View style={[a.flex_1, a.gap_xs]}>
<ModuleHeader.TitleText style={[a.text_lg]}>
{item.feed.displayName}
</ModuleHeader.TitleText>
<ModuleHeader.SubtitleText>
<Trans>
By {sanitizeHandle(item.feed.creator.handle, '@')}
</Trans>
</ModuleHeader.SubtitleText>
</View>
</ModuleHeader.FeedLink>
<ModuleHeader.PinButton feed={item.feed} />
</ModuleHeader.Container>
)
}
case 'preview:footer': {
return <View style={[a.w_full, a.pt_2xl]} />
}
case 'preview:sliceItem': {
const slice = item.slice
const indexInSlice = item.indexInSlice
const subItem = slice.items[indexInSlice]
return (
<PostFeedItem
post={subItem.post}
record={subItem.record}
reason={indexInSlice === 0 ? slice.reason : undefined}
feedContext={slice.feedContext}
moderation={subItem.moderation}
parentAuthor={subItem.parentAuthor}
showReplyTo={item.showReplyTo}
isThreadParent={isThreadParentAt(slice.items, indexInSlice)}
isThreadChild={isThreadChildAt(slice.items, indexInSlice)}
isThreadLastChild={
isThreadChildAt(slice.items, indexInSlice) &&
slice.items.length === indexInSlice + 1
}
isParentBlocked={subItem.isParentBlocked}
isParentNotFound={subItem.isParentNotFound}
hideTopBorder={item.hideTopBorder}
rootPost={slice.items[0].post}
/>
)
}
case 'preview:sliceViewFullThread': {
return <ViewFullThread uri={item.uri} />
}
case 'preview:loadMoreError': {
return (
<LoadMoreRetryBtn
label={_(
msg`There was an issue fetching posts. Tap here to try again.`,
)}
onPress={fetchNextPageFeedPreviews}
/>
)
}
}
},
[
t,
focusSearchInput,
moderationOpts,
selectedInterest,
_,
fetchNextPageFeedPreviews,
headerHeight,
],
)
const stickyHeaderIndices = useMemo(
() =>
items.reduce(
(acc, curr) =>
['topBorder', 'preview:header'].includes(curr.type)
? acc.concat(items.indexOf(curr))
: acc,
[] as number[],
),
[items],
)
// track headers and report module viewability
const alreadyReportedRef = useRef<Map<string, string>>(new Map())
const onViewableItemsChanged = useCallback(
({
viewableItems,
}: {
viewableItems: ViewToken<ExploreScreenItems>[]
changed: ViewToken<ExploreScreenItems>[]
}) => {
for (const {item} of viewableItems.filter(vi => vi.isViewable)) {
let module: MetricEvents['explore:module:seen']['module']
if (item.type === 'trendingTopics' || item.type === 'trendingVideos') {
module = item.type
} else if (item.type === 'profile') {
module = 'suggestedAccounts'
} else if (item.type === 'feed') {
module = 'suggestedFeeds'
} else if (item.type === 'preview:header') {
module = `feed:feedgen|${item.feed.uri}`
} else {
continue
}
if (!alreadyReportedRef.current.has(module)) {
alreadyReportedRef.current.set(module, module)
logger.metric('explore:module:seen', {module})
}
}
},
[],
)
return (
<List
data={items}
renderItem={renderItem}
keyExtractor={item => item.key}
desktopFixedHeight
contentContainerStyle={{paddingBottom: 100}}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag"
stickyHeaderIndices={native(stickyHeaderIndices)}
viewabilityConfig={viewabilityConfig}
onViewableItemsChanged={onViewableItemsChanged}
onEndReached={onLoadMoreFeedPreviews}
onEndReachedThreshold={2}
/>
)
}
const viewabilityConfig: ViewabilityConfig = {
itemVisiblePercentThreshold: 100,
}
+338
View File
@@ -0,0 +1,338 @@
import {memo, useCallback, useMemo, useState} from 'react'
import {ActivityIndicator, View} from 'react-native'
import {type AppBskyFeedDefs} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
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 {useSession} from '#/state/session'
import {Pager} from '#/view/com/pager/Pager'
import {TabBar} from '#/view/com/pager/TabBar'
import {Post} from '#/view/com/post/Post'
import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard'
import {List} from '#/view/com/util/List'
import {atoms as a, useTheme, web} from '#/alf'
import * as FeedCard from '#/components/FeedCard'
import * as Layout from '#/components/Layout'
import {Text} from '#/components/Typography'
let SearchResults = ({
query,
queryWithParams,
activeTab,
onPageSelected,
headerHeight,
}: {
query: string
queryWithParams: string
activeTab: number
onPageSelected: (page: number) => void
headerHeight: number
}): React.ReactNode => {
const {_} = useLingui()
const sections = useMemo(() => {
if (!queryWithParams) return []
const noParams = queryWithParams === query
return [
{
title: _(msg`Top`),
component: (
<SearchScreenPostResults
query={queryWithParams}
sort="top"
active={activeTab === 0}
/>
),
},
{
title: _(msg`Latest`),
component: (
<SearchScreenPostResults
query={queryWithParams}
sort="latest"
active={activeTab === 1}
/>
),
},
noParams && {
title: _(msg`People`),
component: (
<SearchScreenUserResults query={query} active={activeTab === 2} />
),
},
noParams && {
title: _(msg`Feeds`),
component: (
<SearchScreenFeedsResults query={query} active={activeTab === 3} />
),
},
].filter(Boolean) as {
title: string
component: React.ReactNode
}[]
}, [_, query, queryWithParams, activeTab])
return (
<Pager
onPageSelected={onPageSelected}
renderTabBar={props => (
<Layout.Center style={[a.z_10, web([a.sticky, {top: headerHeight}])]}>
<TabBar items={sections.map(section => section.title)} {...props} />
</Layout.Center>
)}
initialPage={0}>
{sections.map((section, i) => (
<View key={i}>{section.component}</View>
))}
</Pager>
)
}
SearchResults = memo(SearchResults)
export {SearchResults}
function Loader() {
return (
<Layout.Content>
<View style={[a.py_xl]}>
<ActivityIndicator />
</View>
</Layout.Content>
)
}
function EmptyState({message, error}: {message: string; error?: string}) {
const t = useTheme()
return (
<Layout.Content>
<View style={[a.p_xl]}>
<View style={[t.atoms.bg_contrast_25, a.rounded_sm, a.p_lg]}>
<Text style={[a.text_md]}>{message}</Text>
{error && (
<>
<View
style={[
{
marginVertical: 12,
height: 1,
width: '100%',
backgroundColor: t.atoms.text.color,
opacity: 0.2,
},
]}
/>
<Text style={[t.atoms.text_contrast_medium]}>
<Trans>Error: {error}</Trans>
</Text>
</>
)}
</View>
</View>
</Layout.Content>
)
}
type SearchResultSlice =
| {
type: 'post'
key: string
post: AppBskyFeedDefs.PostView
}
| {
type: 'loadingMore'
key: string
}
let SearchScreenPostResults = ({
query,
sort,
active,
}: {
query: string
sort?: 'top' | 'latest'
active: boolean
}): React.ReactNode => {
const {_} = useLingui()
const {currentAccount} = useSession()
const [isPTR, setIsPTR] = useState(false)
const augmentedQuery = useMemo(() => {
return augmentSearchQuery(query || '', {did: currentAccount?.did})
}, [query, currentAccount])
const {
isFetched,
data: results,
isFetching,
error,
refetch,
fetchNextPage,
isFetchingNextPage,
hasNextPage,
} = useSearchPostsQuery({query: augmentedQuery, sort, enabled: active})
const onPullToRefresh = useCallback(async () => {
setIsPTR(true)
await refetch()
setIsPTR(false)
}, [setIsPTR, refetch])
const onEndReached = useCallback(() => {
if (isFetching || !hasNextPage || error) return
fetchNextPage()
}, [isFetching, error, hasNextPage, fetchNextPage])
const posts = useMemo(() => {
return results?.pages.flatMap(page => page.posts) || []
}, [results])
const items = useMemo(() => {
let temp: SearchResultSlice[] = []
const seenUris = new Set()
for (const post of posts) {
if (seenUris.has(post.uri)) {
continue
}
temp.push({
type: 'post',
key: post.uri,
post,
})
seenUris.add(post.uri)
}
if (isFetchingNextPage) {
temp.push({
type: 'loadingMore',
key: 'loadingMore',
})
}
return temp
}, [posts, isFetchingNextPage])
return error ? (
<EmptyState
message={_(
msg`We're sorry, but your search could not be completed. Please try again in a few minutes.`,
)}
error={error.toString()}
/>
) : (
<>
{isFetched ? (
<>
{posts.length ? (
<List
data={items}
renderItem={({item}) => {
if (item.type === 'post') {
return <Post post={item.post} />
} else {
return null
}
}}
keyExtractor={item => item.key}
refreshing={isPTR}
onRefresh={onPullToRefresh}
onEndReached={onEndReached}
desktopFixedHeight
contentContainerStyle={{paddingBottom: 100}}
/>
) : (
<EmptyState message={_(msg`No results found for ${query}`)} />
)}
</>
) : (
<Loader />
)}
</>
)
}
SearchScreenPostResults = memo(SearchScreenPostResults)
let SearchScreenUserResults = ({
query,
active,
}: {
query: string
active: boolean
}): React.ReactNode => {
const {_} = useLingui()
const {data: results, isFetched} = useActorSearch({
query,
enabled: active,
})
return isFetched && results ? (
<>
{results.length ? (
<List
data={results}
renderItem={({item}) => (
<ProfileCardWithFollowBtn profile={item} noBg />
)}
keyExtractor={item => item.did}
desktopFixedHeight
contentContainerStyle={{paddingBottom: 100}}
/>
) : (
<EmptyState message={_(msg`No results found for ${query}`)} />
)}
</>
) : (
<Loader />
)
}
SearchScreenUserResults = memo(SearchScreenUserResults)
let SearchScreenFeedsResults = ({
query,
active,
}: {
query: string
active: boolean
}): React.ReactNode => {
const t = useTheme()
const {_} = useLingui()
const {data: results, isFetched} = usePopularFeedsSearch({
query,
enabled: active,
})
return isFetched && results ? (
<>
{results.length ? (
<List
data={results}
renderItem={({item}) => (
<View
style={[
a.border_b,
t.atoms.border_contrast_low,
a.px_lg,
a.py_lg,
]}>
<FeedCard.Default view={item} />
</View>
)}
keyExtractor={item => item.uri}
desktopFixedHeight
contentContainerStyle={{paddingBottom: 100}}
/>
) : (
<EmptyState message={_(msg`No results found for ${query}`)} />
)}
</>
) : (
<Loader />
)
}
SearchScreenFeedsResults = memo(SearchScreenFeedsResults)
+535
View File
@@ -0,0 +1,535 @@
import {
memo,
useCallback,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react'
import {
type StyleProp,
type TextInput,
View,
type ViewStyle,
} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useFocusEffect, useNavigation, useRoute} from '@react-navigation/native'
import {useQueryClient} from '@tanstack/react-query'
import {HITSLOP_20} from '#/lib/constants'
import {HITSLOP_10} from '#/lib/constants'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {MagnifyingGlassIcon} from '#/lib/icons'
import {type NavigationProp} from '#/lib/routes/types'
import {isWeb} from '#/platform/detection'
import {listenSoftReset} from '#/state/events'
import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete'
import {
unstableCacheProfileView,
useProfilesQuery,
} from '#/state/queries/profile'
import {useSession} from '#/state/session'
import {useSetMinimalShellMode} from '#/state/shell'
import {
makeSearchQuery,
type Params,
parseSearchQuery,
} from '#/screens/Search/utils'
import {atoms as a, tokens, useBreakpoints, useTheme, web} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import {SearchInput} from '#/components/forms/SearchInput'
import * as Layout from '#/components/Layout'
import {Text} from '#/components/Typography'
import {account, useStorage} from '#/storage'
import type * as bsky from '#/types/bsky'
import {AutocompleteResults} from './components/AutocompleteResults'
import {SearchHistory} from './components/SearchHistory'
import {SearchLanguageDropdown} from './components/SearchLanguageDropdown'
import {Explore} from './Explore'
import {SearchResults} from './SearchResults'
export function SearchScreenShell({
queryParam,
testID,
fixedParams,
navButton = 'menu',
inputPlaceholder,
}: {
queryParam: string
testID: string
fixedParams?: Params
navButton?: 'back' | 'menu'
inputPlaceholder?: string
}) {
const t = useTheme()
const {gtMobile} = useBreakpoints()
const navigation = useNavigation<NavigationProp>()
const route = useRoute()
const textInput = useRef<TextInput>(null)
const {_} = useLingui()
const setMinimalShellMode = useSetMinimalShellMode()
const {currentAccount} = useSession()
const queryClient = useQueryClient()
// Query terms
const [searchText, setSearchText] = useState<string>(queryParam)
const {data: autocompleteData, isFetching: isAutocompleteFetching} =
useActorAutocompleteQuery(searchText, true)
const [showAutocomplete, setShowAutocomplete] = useState(false)
const [termHistory = [], setTermHistory] = useStorage(account, [
currentAccount?.did ?? 'pwi',
'searchTermHistory',
] as const)
const [accountHistory = [], setAccountHistory] = useStorage(account, [
currentAccount?.did ?? 'pwi',
'searchAccountHistory',
])
const {data: accountHistoryProfiles} = useProfilesQuery({
handles: accountHistory,
maintainData: true,
})
const updateSearchHistory = useCallback(
async (item: string) => {
if (!item) return
const newSearchHistory = [
item,
...termHistory.filter(search => search !== item),
].slice(0, 6)
setTermHistory(newSearchHistory)
},
[termHistory, setTermHistory],
)
const updateProfileHistory = useCallback(
async (item: bsky.profile.AnyProfileView) => {
const newAccountHistory = [
item.did,
...accountHistory.filter(p => p !== item.did),
].slice(0, 5)
setAccountHistory(newAccountHistory)
},
[accountHistory, setAccountHistory],
)
const deleteSearchHistoryItem = useCallback(
async (item: string) => {
setTermHistory(termHistory.filter(search => search !== item))
},
[termHistory, setTermHistory],
)
const deleteProfileHistoryItem = useCallback(
async (item: bsky.profile.AnyProfileView) => {
setAccountHistory(accountHistory.filter(p => p !== item.did))
},
[accountHistory, setAccountHistory],
)
const {params, query, queryWithParams} = useQueryManager({
initialQuery: queryParam,
fixedParams,
})
const showFilters = Boolean(queryWithParams && !showAutocomplete)
// web only - measure header height for sticky positioning
const [headerHeight, setHeaderHeight] = useState(0)
const headerRef = useRef(null)
useLayoutEffect(() => {
if (isWeb) {
if (!headerRef.current) return
const measurement = (headerRef.current as Element).getBoundingClientRect()
setHeaderHeight(measurement.height)
}
}, [])
useFocusEffect(
useNonReactiveCallback(() => {
if (isWeb) {
setSearchText(queryParam)
}
}),
)
const onPressClearQuery = useCallback(() => {
scrollToTopWeb()
setSearchText('')
textInput.current?.focus()
}, [])
const onChangeText = useCallback(async (text: string) => {
scrollToTopWeb()
setSearchText(text)
}, [])
const navigateToItem = useCallback(
(item: string) => {
scrollToTopWeb()
setShowAutocomplete(false)
updateSearchHistory(item)
if (isWeb) {
// @ts-expect-error route is not typesafe
navigation.push(route.name, {...route.params, q: item})
} else {
textInput.current?.blur()
navigation.setParams({q: item})
}
},
[updateSearchHistory, navigation, route],
)
const onPressCancelSearch = useCallback(() => {
scrollToTopWeb()
textInput.current?.blur()
setShowAutocomplete(false)
if (isWeb) {
// Empty params resets the URL to be /search rather than /search?q=
const {q: _q, ...parameters} = (route.params ?? {}) as {
[key: string]: string
}
// @ts-expect-error route is not typesafe
navigation.replace(route.name, parameters)
} else {
setSearchText('')
navigation.setParams({q: ''})
}
}, [setShowAutocomplete, setSearchText, navigation, route.params, route.name])
const onSubmit = useCallback(() => {
navigateToItem(searchText)
}, [navigateToItem, searchText])
const onAutocompleteResultPress = useCallback(() => {
if (isWeb) {
setShowAutocomplete(false)
} else {
textInput.current?.blur()
}
}, [])
const handleHistoryItemClick = useCallback(
(item: string) => {
setSearchText(item)
navigateToItem(item)
},
[navigateToItem],
)
const handleProfileClick = useCallback(
(profile: bsky.profile.AnyProfileView) => {
unstableCacheProfileView(queryClient, profile)
// Slight delay to avoid updating during push nav animation.
setTimeout(() => {
updateProfileHistory(profile)
}, 400)
},
[updateProfileHistory, queryClient],
)
const onSoftReset = useCallback(() => {
if (isWeb) {
// Empty params resets the URL to be /search rather than /search?q=
const {q: _q, ...parameters} = (route.params ?? {}) as {
[key: string]: string
}
// @ts-expect-error route is not typesafe
navigation.replace(route.name, parameters)
} else {
setSearchText('')
navigation.setParams({q: ''})
textInput.current?.focus()
}
}, [navigation, route])
useFocusEffect(
useCallback(() => {
setMinimalShellMode(false)
return listenSoftReset(onSoftReset)
}, [onSoftReset, setMinimalShellMode]),
)
const onSearchInputFocus = useCallback(() => {
if (isWeb) {
// Prevent a jump on iPad by ensuring that
// the initial focused render has no result list.
requestAnimationFrame(() => {
setShowAutocomplete(true)
})
} else {
setShowAutocomplete(true)
}
}, [setShowAutocomplete])
const focusSearchInput = useCallback(() => {
textInput.current?.focus()
}, [])
const showHeader = !gtMobile || navButton !== 'menu'
return (
<Layout.Screen testID={testID}>
<View
ref={headerRef}
onLayout={evt => {
if (isWeb) setHeaderHeight(evt.nativeEvent.layout.height)
}}
style={[
a.relative,
a.z_10,
web({
position: 'sticky',
top: 0,
}),
]}>
<Layout.Center style={t.atoms.bg}>
{showHeader && (
<View
// HACK: shift up search input. we can't remove the top padding
// on the search input because it messes up the layout animation
// if we add it only when the header is hidden
style={{marginBottom: tokens.space.xs * -1}}>
<Layout.Header.Outer noBottomBorder>
{navButton === 'menu' ? (
<Layout.Header.MenuButton />
) : (
<Layout.Header.BackButton />
)}
<Layout.Header.Content align="left">
<Layout.Header.TitleText>
<Trans>Search</Trans>
</Layout.Header.TitleText>
</Layout.Header.Content>
{showFilters ? (
<SearchLanguageDropdown
value={params.lang}
onChange={params.setLang}
/>
) : (
<Layout.Header.Slot />
)}
</Layout.Header.Outer>
</View>
)}
<View style={[a.px_md, a.pt_sm, a.pb_sm, a.overflow_hidden]}>
<View style={[a.gap_sm]}>
<View style={[a.w_full, a.flex_row, a.align_stretch, a.gap_xs]}>
<View style={[a.flex_1]}>
<SearchInput
ref={textInput}
value={searchText}
onFocus={onSearchInputFocus}
onChangeText={onChangeText}
onClearText={onPressClearQuery}
onSubmitEditing={onSubmit}
placeholder={
inputPlaceholder ??
_(msg`Search for posts, users, or feeds`)
}
hitSlop={{...HITSLOP_20, top: 0}}
/>
</View>
{showAutocomplete && (
<Button
label={_(msg`Cancel search`)}
size="large"
variant="ghost"
color="secondary"
style={[a.px_sm]}
onPress={onPressCancelSearch}
hitSlop={HITSLOP_10}>
<ButtonText>
<Trans>Cancel</Trans>
</ButtonText>
</Button>
)}
</View>
{showFilters && !showHeader && (
<View
style={[
a.flex_row,
a.align_center,
a.justify_between,
a.gap_sm,
]}>
<SearchLanguageDropdown
value={params.lang}
onChange={params.setLang}
/>
</View>
)}
</View>
</View>
</Layout.Center>
</View>
<View
style={{
display: showAutocomplete && !fixedParams ? 'flex' : 'none',
flex: 1,
}}>
{searchText.length > 0 ? (
<AutocompleteResults
isAutocompleteFetching={isAutocompleteFetching}
autocompleteData={autocompleteData}
searchText={searchText}
onSubmit={onSubmit}
onResultPress={onAutocompleteResultPress}
onProfileClick={handleProfileClick}
/>
) : (
<SearchHistory
searchHistory={termHistory}
selectedProfiles={accountHistoryProfiles?.profiles || []}
onItemClick={handleHistoryItemClick}
onProfileClick={handleProfileClick}
onRemoveItemClick={deleteSearchHistoryItem}
onRemoveProfileClick={deleteProfileHistoryItem}
/>
)}
</View>
<View
style={{
display: showAutocomplete ? 'none' : 'flex',
flex: 1,
}}>
<SearchScreenInner
query={query}
queryWithParams={queryWithParams}
headerHeight={headerHeight}
focusSearchInput={focusSearchInput}
/>
</View>
</Layout.Screen>
)
}
let SearchScreenInner = ({
query,
queryWithParams,
headerHeight,
focusSearchInput,
}: {
query: string
queryWithParams: string
headerHeight: number
focusSearchInput: () => void
}): React.ReactNode => {
const t = useTheme()
const setMinimalShellMode = useSetMinimalShellMode()
const {hasSession} = useSession()
const {gtTablet} = useBreakpoints()
const [activeTab, setActiveTab] = useState(0)
const {_} = useLingui()
const onPageSelected = useCallback(
(index: number) => {
setMinimalShellMode(false)
setActiveTab(index)
},
[setMinimalShellMode],
)
return queryWithParams ? (
<SearchResults
query={query}
queryWithParams={queryWithParams}
activeTab={activeTab}
headerHeight={headerHeight}
onPageSelected={onPageSelected}
/>
) : hasSession ? (
<Explore focusSearchInput={focusSearchInput} headerHeight={headerHeight} />
) : (
<Layout.Center>
<View style={a.flex_1}>
{gtTablet && (
<View
style={[
a.border_b,
t.atoms.border_contrast_low,
a.px_lg,
a.pt_sm,
a.pb_lg,
]}>
<Text style={[a.text_2xl, a.font_heavy]}>
<Trans>Search</Trans>
</Text>
</View>
)}
<View style={[a.align_center, a.justify_center, a.py_4xl, a.gap_lg]}>
<MagnifyingGlassIcon
strokeWidth={3}
size={60}
style={t.atoms.text_contrast_medium as StyleProp<ViewStyle>}
/>
<Text style={[t.atoms.text_contrast_medium, a.text_md]}>
<Trans>Find posts, users, and feeds on Bluesky</Trans>
</Text>
</View>
</View>
</Layout.Center>
)
}
SearchScreenInner = memo(SearchScreenInner)
function useQueryManager({
initialQuery,
fixedParams,
}: {
initialQuery: string
fixedParams?: Params
}) {
const {query, params: initialParams} = useMemo(() => {
return parseSearchQuery(initialQuery || '')
}, [initialQuery])
const [prevInitialQuery, setPrevInitialQuery] = useState(initialQuery)
const [lang, setLang] = useState(initialParams.lang || '')
if (initialQuery !== prevInitialQuery) {
// handle new queryParam change (from manual search entry)
setPrevInitialQuery(initialQuery)
setLang(initialParams.lang || '')
}
const params = useMemo(
() => ({
// default stuff
...initialParams,
// managed stuff
lang,
...fixedParams,
}),
[lang, initialParams, fixedParams],
)
const handlers = useMemo(
() => ({
setLang,
}),
[setLang],
)
return useMemo(() => {
return {
query,
queryWithParams: makeSearchQuery(query, params),
params: {
...params,
...handlers,
},
}
}, [query, params, handlers])
}
function scrollToTopWeb() {
if (isWeb) {
window.scrollTo(0, 0)
}
}
@@ -0,0 +1,71 @@
import {memo} from 'react'
import {ActivityIndicator, View} from 'react-native'
import {type AppBskyActorDefs, moderateProfile} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {isNative} from '#/platform/detection'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {SearchLinkCard, SearchProfileCard} from '#/view/shell/desktop/Search'
import {atoms as a, native} from '#/alf'
import * as Layout from '#/components/Layout'
let AutocompleteResults = ({
isAutocompleteFetching,
autocompleteData,
searchText,
onSubmit,
onResultPress,
onProfileClick,
}: {
isAutocompleteFetching: boolean
autocompleteData: AppBskyActorDefs.ProfileViewBasic[] | undefined
searchText: string
onSubmit: () => void
onResultPress: () => void
onProfileClick: (profile: AppBskyActorDefs.ProfileViewBasic) => void
}): React.ReactNode => {
const moderationOpts = useModerationOpts()
const {_} = useLingui()
return (
<>
{(isAutocompleteFetching && !autocompleteData?.length) ||
!moderationOpts ? (
<Layout.Content>
<View style={[a.py_xl]}>
<ActivityIndicator />
</View>
</Layout.Content>
) : (
<Layout.Content
keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag">
<SearchLinkCard
label={_(msg`Search for "${searchText}"`)}
onPress={native(onSubmit)}
to={
isNative
? undefined
: `/search?q=${encodeURIComponent(searchText)}`
}
style={{borderBottomWidth: 1}}
/>
{autocompleteData?.map(item => (
<SearchProfileCard
key={item.did}
profile={item}
moderation={moderateProfile(item, moderationOpts)}
onPress={() => {
onProfileClick(item)
onResultPress()
}}
/>
))}
<View style={{height: 200}} />
</Layout.Content>
)}
</>
)
}
AutocompleteResults = memo(AutocompleteResults)
export {AutocompleteResults}
@@ -1,142 +0,0 @@
import React from 'react'
import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {logEvent} from '#/lib/statsig/statsig'
import {isWeb} from '#/platform/detection'
import {
useTrendingSettings,
useTrendingSettingsApi,
} from '#/state/preferences/trending'
import {
DEFAULT_LIMIT as TRENDING_TOPICS_COUNT,
useTrendingTopics,
} from '#/state/queries/trending/useTrendingTopics'
import {useTrendingConfig} from '#/state/trending-config'
import {atoms as a, tokens, useGutters, useTheme} from '#/alf'
import {Button, ButtonIcon} from '#/components/Button'
import {GradientFill} from '#/components/GradientFill'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import {Trending2_Stroke2_Corner2_Rounded as Trending} from '#/components/icons/Trending2'
import * as Prompt from '#/components/Prompt'
import {
TrendingTopic,
TrendingTopicLink,
TrendingTopicSkeleton,
} from '#/components/TrendingTopics'
import {Text} from '#/components/Typography'
export function ExploreTrendingTopics() {
const {enabled} = useTrendingConfig()
const {trendingDisabled} = useTrendingSettings()
return enabled && !trendingDisabled ? <Inner /> : null
}
function Inner() {
const t = useTheme()
const {_} = useLingui()
const gutters = useGutters([0, 'compact'])
const {data: trending, error, isLoading} = useTrendingTopics()
const noTopics = !isLoading && !error && !trending?.topics?.length
const {setTrendingDisabled} = useTrendingSettingsApi()
const trendingPrompt = Prompt.usePromptControl()
const onConfirmHide = React.useCallback(() => {
logEvent('trendingTopics:hide', {context: 'explore:trending'})
setTrendingDisabled(true)
}, [setTrendingDisabled])
return error || noTopics ? null : (
<>
<View
style={[
a.flex_row,
isWeb
? [a.px_lg, a.py_lg, a.pt_2xl, a.gap_md]
: [a.p_lg, a.pt_2xl, a.gap_md],
a.border_b,
t.atoms.border_contrast_low,
]}>
<View style={[a.flex_1, a.gap_sm]}>
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
<Trending
size="lg"
fill={t.palette.primary_500}
style={{marginLeft: -2}}
/>
<Text style={[a.text_2xl, a.font_heavy, t.atoms.text]}>
<Trans>Trending</Trans>
</Text>
<View style={[a.py_xs, a.px_sm, a.rounded_sm, a.overflow_hidden]}>
<GradientFill gradient={tokens.gradients.primary} />
<Text style={[a.text_sm, a.font_heavy, {color: 'white'}]}>
<Trans>BETA</Trans>
</Text>
</View>
</View>
<Text style={[t.atoms.text_contrast_high, a.leading_snug]}>
<Trans>What people are posting about.</Trans>
</Text>
</View>
<Button
label={_(msg`Hide trending topics`)}
size="small"
variant="ghost"
color="secondary"
shape="round"
onPress={() => trendingPrompt.open()}>
<ButtonIcon icon={X} />
</Button>
</View>
<View style={[a.pt_md, a.pb_lg]}>
<View
style={[
a.flex_row,
a.justify_start,
a.flex_wrap,
{rowGap: 8, columnGap: 6},
gutters,
]}>
{isLoading ? (
Array(TRENDING_TOPICS_COUNT)
.fill(0)
.map((_, i) => <TrendingTopicSkeleton key={i} index={i} />)
) : !trending?.topics ? null : (
<>
{trending.topics.map(topic => (
<TrendingTopicLink
key={topic.link}
topic={topic}
onPress={() => {
logEvent('trendingTopic:click', {context: 'explore'})
}}>
{({hovered}) => (
<TrendingTopic
topic={topic}
style={[
hovered && [
t.atoms.border_contrast_high,
t.atoms.bg_contrast_25,
],
]}
/>
)}
</TrendingTopicLink>
))}
</>
)}
</View>
</View>
<Prompt.Basic
control={trendingPrompt}
title={_(msg`Hide trending topics?`)}
description={_(msg`You can update this later from your settings.`)}
confirmButtonCta={_(msg`Hide`)}
onConfirm={onConfirmHide}
/>
</>
)
}
@@ -0,0 +1,170 @@
import {useMemo} from 'react'
import {View} from 'react-native'
import {type AppBskyFeedDefs, AtUri} from '@atproto/api'
import {PressableScale} from '#/lib/custom-animations/PressableScale'
import {makeCustomFeedLink} from '#/lib/routes/links'
import {logger} from '#/logger'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {
atoms as a,
native,
useGutters,
useTheme,
type ViewStyleProp,
web,
} from '#/alf'
import {Button, ButtonIcon} from '#/components/Button'
import * as FeedCard from '#/components/FeedCard'
import {sizes as iconSizes} from '#/components/icons/common'
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as SearchIcon} from '#/components/icons/MagnifyingGlass2'
import {Link} from '#/components/Link'
import {Text, type TextProps} from '#/components/Typography'
export function Container({
style,
children,
headerHeight,
}: {children: React.ReactNode; headerHeight?: number} & ViewStyleProp) {
const t = useTheme()
const gutters = useGutters([0, 'base'])
return (
<View
style={[
gutters,
a.flex_row,
a.align_center,
a.pt_2xl,
a.pb_md,
a.gap_sm,
t.atoms.bg,
headerHeight && web({position: 'sticky', top: headerHeight}),
style,
]}>
{children}
</View>
)
}
export function FeedLink({
feed,
children,
}: {
feed: AppBskyFeedDefs.GeneratorView
children?: React.ReactNode
}) {
const t = useTheme()
const {host: did, rkey} = useMemo(() => new AtUri(feed.uri), [feed.uri])
return (
<Link
to={makeCustomFeedLink(did, rkey)}
label={feed.displayName}
style={[a.flex_1]}>
{({focused, hovered, pressed}) => (
<View
style={[
a.flex_1,
a.flex_row,
a.align_center,
{gap: 10},
a.rounded_md,
a.p_xs,
{marginLeft: -6},
(focused || hovered || pressed) && t.atoms.bg_contrast_25,
]}>
{children}
</View>
)}
</Link>
)
}
export function FeedAvatar({feed}: {feed: AppBskyFeedDefs.GeneratorView}) {
return <UserAvatar type="algo" size={38} avatar={feed.avatar} />
}
export function Icon({
icon: Comp,
size = 'lg',
}: Pick<React.ComponentProps<typeof ButtonIcon>, 'icon' | 'size'>) {
const iconSize = iconSizes[size]
return (
<View style={[a.z_20, {width: iconSize, height: iconSize, marginLeft: -2}]}>
<Comp width={iconSize} />
</View>
)
}
export function TitleText({style, ...props}: TextProps) {
return (
<Text style={[a.font_bold, a.flex_1, a.text_xl, style]} emoji {...props} />
)
}
export function SubtitleText({style, ...props}: TextProps) {
const t = useTheme()
return (
<Text
style={[
t.atoms.text_contrast_medium,
a.leading_tight,
a.flex_1,
a.text_sm,
style,
]}
{...props}
/>
)
}
export function SearchButton({
label,
metricsTag,
onPress,
}: {
label: string
metricsTag: 'suggestedAccounts' | 'suggestedFeeds'
onPress?: () => void
}) {
return (
<Button
label={label}
size="small"
variant="ghost"
color="secondary"
shape="round"
PressableComponent={native(PressableScale)}
onPress={() => {
logger.metric(
'explore:module:searchButtonPress',
{module: metricsTag},
{statsig: true},
)
onPress?.()
}}
style={[
{
right: -4,
},
]}>
<ButtonIcon icon={SearchIcon} size="lg" />
</Button>
)
}
export function PinButton({feed}: {feed: AppBskyFeedDefs.GeneratorView}) {
return (
<View style={[a.z_20, {marginRight: -6}]}>
<FeedCard.SaveButton
pin
view={feed}
size="large"
color="secondary"
variant="ghost"
shape="square"
text={false}
/>
</View>
)
}
@@ -0,0 +1,169 @@
import {Pressable, ScrollView, StyleSheet, View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {createHitslop, HITSLOP_10} from '#/lib/constants'
import {makeProfileLink} from '#/lib/routes/links'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {Link} from '#/view/com/util/Link'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture'
import {atoms as a, tokens, useBreakpoints, useTheme} from '#/alf'
import {Button, ButtonIcon} from '#/components/Button'
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
import * as Layout from '#/components/Layout'
import {Text} from '#/components/Typography'
import type * as bsky from '#/types/bsky'
export function SearchHistory({
searchHistory,
selectedProfiles,
onItemClick,
onProfileClick,
onRemoveItemClick,
onRemoveProfileClick,
}: {
searchHistory: string[]
selectedProfiles: bsky.profile.AnyProfileView[]
onItemClick: (item: string) => void
onProfileClick: (profile: bsky.profile.AnyProfileView) => void
onRemoveItemClick: (item: string) => void
onRemoveProfileClick: (profile: bsky.profile.AnyProfileView) => void
}) {
const {gtMobile} = useBreakpoints()
const t = useTheme()
const {_} = useLingui()
return (
<Layout.Content
keyboardDismissMode="interactive"
keyboardShouldPersistTaps="handled">
<View style={[a.w_full, a.px_md]}>
{(searchHistory.length > 0 || selectedProfiles.length > 0) && (
<Text style={[a.text_md, a.font_bold, a.p_md]}>
<Trans>Recent Searches</Trans>
</Text>
)}
{selectedProfiles.length > 0 && (
<View
style={[
styles.selectedProfilesContainer,
!gtMobile && styles.selectedProfilesContainerMobile,
]}>
<BlockDrawerGesture>
<ScrollView
horizontal
keyboardShouldPersistTaps="handled"
style={[
a.flex_row,
a.flex_nowrap,
{marginHorizontal: tokens.space._2xl * -1},
]}
contentContainerStyle={[a.px_2xl, a.border_0]}>
{selectedProfiles.slice(0, 5).map((profile, index) => (
<View
key={index}
style={[
styles.profileItem,
!gtMobile && styles.profileItemMobile,
]}>
<Link
href={makeProfileLink(profile)}
title={profile.handle}
asAnchor
anchorNoUnderline
onBeforePress={() => onProfileClick(profile)}
style={[a.align_center, a.w_full]}>
<UserAvatar
avatar={profile.avatar}
type={profile.associated?.labeler ? 'labeler' : 'user'}
size={60}
/>
<Text
emoji
style={[a.text_xs, a.text_center, styles.profileName]}
numberOfLines={1}>
{sanitizeDisplayName(
profile.displayName || profile.handle,
)}
</Text>
</Link>
<Pressable
accessibilityRole="button"
accessibilityLabel={_(msg`Remove profile`)}
accessibilityHint={_(
msg`Removes profile from search history`,
)}
onPress={() => onRemoveProfileClick(profile)}
hitSlop={createHitslop(6)}
style={styles.profileRemoveBtn}>
<XIcon size="xs" style={t.atoms.text_contrast_low} />
</Pressable>
</View>
))}
</ScrollView>
</BlockDrawerGesture>
</View>
)}
{searchHistory.length > 0 && (
<View style={[a.pl_md, a.pr_xs, a.mt_md]}>
{searchHistory.slice(0, 5).map((historyItem, index) => (
<View key={index} style={[a.flex_row, a.align_center, a.mt_xs]}>
<Pressable
accessibilityRole="button"
onPress={() => onItemClick(historyItem)}
hitSlop={HITSLOP_10}
style={[a.flex_1, a.py_md]}>
<Text style={[a.text_md]}>{historyItem}</Text>
</Pressable>
<Button
label={_(msg`Remove ${historyItem}`)}
onPress={() => onRemoveItemClick(historyItem)}
size="small"
variant="ghost"
color="secondary"
shape="round">
<ButtonIcon icon={XIcon} />
</Button>
</View>
))}
</View>
)}
</View>
</Layout.Content>
)
}
const styles = StyleSheet.create({
selectedProfilesContainer: {
marginTop: 10,
paddingHorizontal: 12,
height: 80,
},
selectedProfilesContainerMobile: {
height: 100,
},
profileItem: {
alignItems: 'center',
marginRight: 15,
width: 78,
},
profileItemMobile: {
width: 70,
},
profileName: {
width: 78,
marginTop: 6,
},
profileRemoveBtn: {
position: 'absolute',
top: 0,
right: 5,
backgroundColor: 'white',
borderRadius: 10,
width: 18,
height: 18,
alignItems: 'center',
justifyContent: 'center',
},
})
@@ -0,0 +1,120 @@
import {useMemo} from 'react'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
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 {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {
ChevronBottom_Stroke2_Corner0_Rounded as ChevronDownIcon,
ChevronTopBottom_Stroke2_Corner0_Rounded as ChevronUpDownIcon,
} from '#/components/icons/Chevron'
import {Earth_Stroke2_Corner0_Rounded as EarthIcon} from '#/components/icons/Globe'
import * as Menu from '#/components/Menu'
export function SearchLanguageDropdown({
value,
onChange,
}: {
value: string
onChange(value: string): void
}) {
const {_} = useLingui()
const {appLanguage, contentLanguages} = useLanguagePrefs()
const languages = useMemo(() => {
return LANGUAGES.filter(
(lang, index, self) =>
Boolean(lang.code2) && // reduce to the code2 varieties
index === self.findIndex(t => t.code2 === lang.code2), // remove dupes (which will happen)
)
.map(l => ({
label: languageName(l, appLanguage),
value: l.code2,
key: l.code2 + l.code3,
}))
.sort((a, b) => {
// prioritize user's languages
const aIsUser = contentLanguages.includes(a.value)
const bIsUser = contentLanguages.includes(b.value)
if (aIsUser && !bIsUser) return -1
if (bIsUser && !aIsUser) return 1
// prioritize "common" langs in the network
const aIsCommon = !!APP_LANGUAGES.find(
al =>
// skip `ast`, because it uses a 3-letter code which conflicts with `as`
// it begins with `a` anyway so still is top of the list
al.code2 !== 'ast' && al.code2.startsWith(a.value),
)
const bIsCommon = !!APP_LANGUAGES.find(
al =>
// ditto
al.code2 !== 'ast' && al.code2.startsWith(b.value),
)
if (aIsCommon && !bIsCommon) return -1
if (bIsCommon && !aIsCommon) return 1
// fall back to alphabetical
return a.label.localeCompare(b.label)
})
}, [appLanguage, contentLanguages])
const currentLanguageLabel =
languages.find(lang => lang.value === value)?.label ?? _(msg`All languages`)
return (
<Menu.Root>
<Menu.Trigger
label={_(
msg`Filter search by language (currently: ${currentLanguageLabel})`,
)}>
{({props}) => (
<Button
{...props}
label={props.accessibilityLabel}
size="small"
color={platform({native: 'primary', default: 'secondary'})}
variant={platform({native: 'ghost', default: 'solid'})}
style={native([
a.py_sm,
a.px_sm,
{marginRight: tokens.space.sm * -1},
])}>
<ButtonIcon icon={EarthIcon} />
<ButtonText>{currentLanguageLabel}</ButtonText>
<ButtonIcon
icon={platform({
native: ChevronUpDownIcon,
default: ChevronDownIcon,
})}
/>
</Button>
)}
</Menu.Trigger>
<Menu.Outer>
<Menu.LabelText>
<Trans>Filter search by language</Trans>
</Menu.LabelText>
<Menu.Item label={_(msg`All languages`)} onPress={() => onChange('')}>
<Menu.ItemText>
<Trans>All languages</Trans>
</Menu.ItemText>
<Menu.ItemRadio selected={value === ''} />
</Menu.Item>
<Menu.Divider />
<Menu.Group>
{languages.map(lang => (
<Menu.Item
key={lang.key}
label={lang.label}
onPress={() => onChange(lang.value)}>
<Menu.ItemText>{lang.label}</Menu.ItemText>
<Menu.ItemRadio selected={value === lang.value} />
</Menu.Item>
))}
</Menu.Group>
</Menu.Outer>
</Menu.Root>
)
}
@@ -0,0 +1,296 @@
import React from 'react'
import {View} from 'react-native'
import {
type AppBskyGraphDefs,
AppBskyGraphStarterpack,
moderateProfile,
} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {sanitizeHandle} from '#/lib/strings/handles'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useSession} from '#/state/session'
import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
import {ButtonText} from '#/components/Button'
import {PlusSmall_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
import {Link} from '#/components/Link'
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
import {useStarterPackLink} from '#/components/StarterPack/StarterPackCard'
import {Text} from '#/components/Typography'
import * as bsky from '#/types/bsky'
export function StarterPackCard({
view,
}: {
view: AppBskyGraphDefs.StarterPackView
}) {
const t = useTheme()
const {_} = useLingui()
const {currentAccount} = useSession()
const {gtPhone} = useBreakpoints()
const link = useStarterPackLink({view})
if (
!bsky.dangerousIsType<AppBskyGraphStarterpack.Record>(
view.record,
AppBskyGraphStarterpack.isRecord,
)
) {
return null
}
const profileCount = gtPhone ? 11 : 8
const profiles = view.listItemsSample
?.slice(0, profileCount)
.map(item => item.subject)
return (
<View
style={[
a.w_full,
a.p_lg,
a.gap_md,
a.border,
a.rounded_sm,
a.overflow_hidden,
t.atoms.border_contrast_low,
]}>
<View aria-hidden style={[a.absolute, a.inset_0, a.z_40]}>
<Link
to={link.to}
label={link.label}
style={[a.absolute, a.inset_0]}
onHoverIn={link.precache}
onPress={link.precache}>
<View />
</Link>
</View>
<AvatarStack
profiles={profiles ?? []}
numPending={profileCount}
total={view.list?.listItemCount}
/>
<View
style={[
a.w_full,
a.flex_row,
a.align_start,
a.gap_lg,
web({
position: 'static',
zIndex: 'unset',
}),
]}>
<View style={[a.flex_1]}>
<Text
emoji
style={[a.text_md, a.font_bold, a.leading_snug]}
numberOfLines={1}>
{view.record.name}
</Text>
<Text
emoji
style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}
numberOfLines={1}>
{view.creator?.did === currentAccount?.did
? _(msg`By you`)
: _(msg`By ${sanitizeHandle(view.creator.handle, '@')}`)}
</Text>
</View>
<Link
to={link.to}
label={link.label}
onHoverIn={link.precache}
onPress={link.precache}
variant="solid"
color="secondary"
size="small"
style={[a.z_50]}>
<ButtonText>
<Trans>Open pack</Trans>
</ButtonText>
</Link>
</View>
</View>
)
}
export function AvatarStack({
profiles,
numPending,
total,
}: {
profiles: bsky.profile.AnyProfileView[]
numPending: number
total?: number
}) {
const t = useTheme()
const {gtPhone} = useBreakpoints()
const moderationOpts = useModerationOpts()
const computedTotal = (total ?? numPending) - numPending
const circlesCount = numPending + 1 // add total at end
const widthPerc = 100 / circlesCount
const [size, setSize] = React.useState<number | null>(null)
const isPending = (numPending && profiles.length === 0) || !moderationOpts
const items = isPending
? Array.from({length: numPending ?? circlesCount}).map((_, i) => ({
key: i,
profile: null,
moderation: null,
}))
: profiles.map(item => ({
key: item.did,
profile: item,
moderation: moderateProfile(item, moderationOpts),
}))
return (
<View
style={[
a.w_full,
a.flex_row,
a.align_center,
a.relative,
{width: `${100 - widthPerc * 0.2}%`},
]}>
{items.map((item, i) => (
<View
key={item.key}
style={[
{
width: `${widthPerc}%`,
zIndex: 100 - i,
},
]}>
<View
style={[
a.relative,
{
width: '120%',
},
]}>
<View
onLayout={e => setSize(e.nativeEvent.layout.width)}
style={[
a.rounded_full,
t.atoms.bg_contrast_25,
{
paddingTop: '100%',
},
]}>
{size && item.profile ? (
<UserAvatar
size={size}
avatar={item.profile.avatar}
type={item.profile.associated?.labeler ? 'labeler' : 'user'}
moderation={item.moderation.ui('avatar')}
style={[a.absolute, a.inset_0]}
/>
) : (
<MediaInsetBorder style={[a.rounded_full]} />
)}
</View>
</View>
</View>
))}
<View
style={[
{
width: `${widthPerc}%`,
zIndex: 1,
},
]}>
<View
style={[
a.relative,
{
width: '120%',
},
]}>
<View
style={[
{
paddingTop: '100%',
},
]}>
<View
style={[
a.absolute,
a.inset_0,
a.rounded_full,
a.align_center,
a.justify_center,
{
backgroundColor: t.atoms.text_contrast_low.color,
},
]}>
{computedTotal > 0 ? (
<Text
style={[
gtPhone ? a.text_md : a.text_sm,
a.font_bold,
a.leading_snug,
{color: 'white'},
]}>
<Trans comment="Indicates the number of additional profiles are in the Starter Pack e.g. +12">
+{computedTotal}
</Trans>
</Text>
) : (
<Plus fill="white" />
)}
</View>
</View>
</View>
</View>
</View>
)
}
export function StarterPackCardSkeleton() {
const t = useTheme()
const {gtPhone} = useBreakpoints()
const profileCount = gtPhone ? 11 : 8
return (
<View
style={[
a.w_full,
a.p_lg,
a.gap_md,
a.border,
a.rounded_sm,
a.overflow_hidden,
t.atoms.border_contrast_low,
]}>
<AvatarStack profiles={[]} numPending={profileCount} />
<View
style={[
a.w_full,
a.flex_row,
a.align_start,
a.gap_lg,
web({
position: 'static',
zIndex: 'unset',
}),
]}>
<View style={[a.flex_1, a.gap_xs]}>
<LoadingPlaceholder width={180} height={18} />
<LoadingPlaceholder width={120} height={14} />
</View>
<LoadingPlaceholder width={100} height={33} />
</View>
</View>
)
}
+13
View File
@@ -0,0 +1,13 @@
import {
type NativeStackScreenProps,
type SearchTabNavigatorParams,
} from '#/lib/routes/types'
import {SearchScreenShell} from './Shell'
export function SearchScreen(
props: NativeStackScreenProps<SearchTabNavigatorParams, 'Search'>,
) {
const queryParam = props.route?.params?.q ?? ''
return <SearchScreenShell queryParam={queryParam} testID="searchScreen" />
}
@@ -0,0 +1,264 @@
import {useMemo} from 'react'
import {type AppBskyFeedDefs, moderatePost} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useInfiniteQuery} from '@tanstack/react-query'
import {CustomFeedAPI} from '#/lib/api/feed/custom'
import {aggregateUserInterests} from '#/lib/api/feed/utils'
import {FeedTuner} from '#/lib/api/feed-manip'
import {cleanError} from '#/lib/strings/errors'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {
type FeedPostSlice,
type FeedPostSliceItem,
} from '#/state/queries/post-feed'
import {usePreferencesQuery} from '#/state/queries/preferences'
import {useAgent} from '#/state/session'
const RQKEY_ROOT = 'feed-previews'
const RQKEY = (feeds: string[]) => [RQKEY_ROOT, feeds]
const LIMIT = 8 // sliced to 6, overfetch to account for moderation
export type FeedPreviewItem =
| {
type: 'topBorder'
key: string
}
| {
type: 'preview:loading'
key: string
}
| {
type: 'preview:error'
key: string
message: string
error: string
}
| {
type: 'preview:loadMoreError'
key: string
}
| {
type: 'preview:empty'
key: string
}
| {
type: 'preview:header'
key: string
feed: AppBskyFeedDefs.GeneratorView
}
| {
type: 'preview:footer'
key: string
}
// copied from PostFeed.tsx
| {
type: 'preview:sliceItem'
key: string
slice: FeedPostSlice
indexInSlice: number
showReplyTo: boolean
hideTopBorder: boolean
}
| {
type: 'preview:sliceViewFullThread'
key: string
uri: string
}
export function useFeedPreviews(feeds: AppBskyFeedDefs.GeneratorView[]) {
const uris = feeds.map(feed => feed.uri)
const {_} = useLingui()
const agent = useAgent()
const {data: preferences} = usePreferencesQuery()
const userInterests = aggregateUserInterests(preferences)
const moderationOpts = useModerationOpts()
const enabled = feeds.length > 0
const query = useInfiniteQuery({
enabled,
queryKey: RQKEY(uris),
queryFn: async ({pageParam}) => {
const feed = feeds[pageParam]
const api = new CustomFeedAPI({
agent,
feedParams: {feed: feed.uri},
userInterests,
})
const data = await api.fetch({cursor: undefined, limit: LIMIT})
return {
feed,
posts: data.feed,
}
},
initialPageParam: 0,
getNextPageParam: (_p, _a, count) =>
count < feeds.length ? count + 1 : undefined,
})
const {data, isFetched, isError, isPending, error} = query
return {
query,
data: useMemo<FeedPreviewItem[]>(() => {
const items: FeedPreviewItem[] = []
if (!enabled) return items
const isEmpty =
!isPending && !data?.pages?.some(page => page.posts.length)
if (isFetched) {
if (isError && isEmpty) {
items.push({
type: 'preview:error',
key: 'error',
message: _(msg`An error occurred while fetching the feed.`),
error: cleanError(error),
})
} else if (isEmpty) {
items.push({
type: 'preview:empty',
key: 'empty',
})
} else if (data) {
for (let pageIndex = 0; pageIndex < data.pages.length; pageIndex++) {
const page = data.pages[pageIndex]
// default feed tuner - we just want it to slice up the feed
const tuner = new FeedTuner([])
const slices: FeedPreviewItem[] = []
let rowIndex = 0
for (const item of tuner.tune(page.posts)) {
if (item.isFallbackMarker) continue
const moderations = item.items.map(item =>
moderatePost(item.post, moderationOpts!),
)
// apply moderation filters
item.items = item.items.filter((_, i) => {
return !moderations[i]?.ui('contentList').filter
})
const slice = {
_reactKey: item._reactKey,
_isFeedPostSlice: true,
isFallbackMarker: false,
isIncompleteThread: item.isIncompleteThread,
feedContext: item.feedContext,
reason: item.reason,
feedPostUri: item.feedPostUri,
items: item.items.slice(0, 6).map((subItem, i) => {
const feedPostSliceItem: FeedPostSliceItem = {
_reactKey: `${item._reactKey}-${i}-${subItem.post.uri}`,
uri: subItem.post.uri,
post: subItem.post,
record: subItem.record,
moderation: moderations[i],
parentAuthor: subItem.parentAuthor,
isParentBlocked: subItem.isParentBlocked,
isParentNotFound: subItem.isParentNotFound,
}
return feedPostSliceItem
}),
}
if (slice.isIncompleteThread && slice.items.length >= 3) {
const beforeLast = slice.items.length - 2
const last = slice.items.length - 1
slices.push({
type: 'preview:sliceItem',
key: slice.items[0]._reactKey,
slice: slice,
indexInSlice: 0,
showReplyTo: false,
hideTopBorder: rowIndex === 0,
})
slices.push({
type: 'preview:sliceViewFullThread',
key: slice._reactKey + '-viewFullThread',
uri: slice.items[0].uri,
})
slices.push({
type: 'preview:sliceItem',
key: slice.items[beforeLast]._reactKey,
slice: slice,
indexInSlice: beforeLast,
showReplyTo:
slice.items[beforeLast].parentAuthor?.did !==
slice.items[beforeLast].post.author.did,
hideTopBorder: false,
})
slices.push({
type: 'preview:sliceItem',
key: slice.items[last]._reactKey,
slice: slice,
indexInSlice: last,
showReplyTo: false,
hideTopBorder: false,
})
} else {
for (let i = 0; i < slice.items.length; i++) {
slices.push({
type: 'preview:sliceItem',
key: slice.items[i]._reactKey,
slice: slice,
indexInSlice: i,
showReplyTo: i === 0,
hideTopBorder: i === 0 && rowIndex === 0,
})
}
}
rowIndex++
}
if (slices.length > 0) {
if (pageIndex > 0) {
items.push({
type: 'topBorder',
key: `topBorder-${page.feed.uri}`,
})
}
items.push(
{
type: 'preview:footer',
key: `footer-${page.feed.uri}`,
},
{
type: 'preview:header',
key: `header-${page.feed.uri}`,
feed: page.feed,
},
...slices,
)
}
}
} else if (isError && !isEmpty) {
items.push({
type: 'preview:loadMoreError',
key: 'loadMoreError',
})
}
} else {
items.push({
type: 'preview:loading',
key: 'loading',
})
}
return items
}, [
enabled,
data,
isFetched,
isError,
isPending,
moderationOpts,
_,
error,
]),
}
}
@@ -1,8 +1,8 @@
import {View} from 'react-native'
import {AppBskyUnspeccedDefs} from '@atproto/api'
import {type AppBskyUnspeccedDefs} from '@atproto/api'
import {Trans} from '@lingui/macro'
import {logEvent} from '#/lib/statsig/statsig'
import {logger} from '#/logger'
import {isWeb} from '#/platform/detection'
import {
DEFAULT_LIMIT as RECOMMENDATIONS_COUNT,
@@ -18,6 +18,8 @@ import {
} from '#/components/TrendingTopics'
import {Text} from '#/components/Typography'
// Note: This module is not currently used and may be removed in the future.
export function ExploreRecommendations() {
const {enabled} = useTrendingConfig()
return enabled ? <Inner /> : null
@@ -86,7 +88,11 @@ function Inner() {
key={topic.link}
topic={topic}
onPress={() => {
logEvent('recommendedTopic:click', {context: 'explore'})
logger.metric(
'recommendedTopic:click',
{context: 'explore'},
{statsig: true},
)
}}>
{({hovered}) => (
<TrendingTopic
@@ -0,0 +1,228 @@
import {memo, useEffect} from 'react'
import {View} from 'react-native'
import {type AppBskyActorSearchActors, type ModerationOpts} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {type InfiniteData} from '@tanstack/react-query'
import {logger} from '#/logger'
import {usePreferencesQuery} from '#/state/queries/preferences'
import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture'
import {
popularInterests,
useInterestsDisplayNames,
} from '#/screens/Onboarding/state'
import {useTheme} from '#/alf'
import {atoms as a} from '#/alf'
import {Button} from '#/components/Button'
import * as ProfileCard from '#/components/ProfileCard'
import {boostInterests, Tabs} from '#/components/ProgressGuide/FollowDialog'
import {Text} from '#/components/Typography'
import type * as bsky from '#/types/bsky'
export function useLoadEnoughProfiles({
interest,
data,
isLoading,
isFetchingNextPage,
hasNextPage,
fetchNextPage,
}: {
interest: string | null
data?: InfiniteData<AppBskyActorSearchActors.OutputSchema>
isLoading: boolean
isFetchingNextPage: boolean
hasNextPage: boolean
fetchNextPage: () => Promise<unknown>
}) {
const profileCount =
data?.pages.flatMap(page =>
page.actors.filter(actor => !actor.viewer?.following),
).length || 0
const isAnyLoading = isLoading || isFetchingNextPage
const isEnoughProfiles = profileCount > 3
const shouldFetchMore = !isEnoughProfiles && hasNextPage && !!interest
useEffect(() => {
if (shouldFetchMore && !isAnyLoading) {
logger.info('Not enough suggested accounts - fetching more')
fetchNextPage()
}
}, [shouldFetchMore, fetchNextPage, isAnyLoading, interest])
return {
isReady: !shouldFetchMore,
}
}
export function SuggestedAccountsTabBar({
selectedInterest,
onSelectInterest,
}: {
selectedInterest: string | null
onSelectInterest: (interest: string | null) => void
}) {
const {_} = useLingui()
const interestsDisplayNames = useInterestsDisplayNames()
const {data: preferences} = usePreferencesQuery()
const personalizedInterests = preferences?.interests?.tags
const interests = Object.keys(interestsDisplayNames)
.sort(boostInterests(popularInterests))
.sort(boostInterests(personalizedInterests))
return (
<BlockDrawerGesture>
<Tabs
interests={['all', ...interests]}
selectedInterest={selectedInterest || 'all'}
onSelectTab={tab => {
logger.metric(
'explore:suggestedAccounts:tabPressed',
{tab: tab},
{statsig: true},
)
onSelectInterest(tab === 'all' ? null : tab)
}}
hasSearchText={false}
interestsDisplayNames={{
all: _(msg`All`),
...interestsDisplayNames,
}}
TabComponent={Tab}
/>
</BlockDrawerGesture>
)
}
let Tab = ({
onSelectTab,
interest,
active,
index,
interestsDisplayName,
onLayout,
}: {
onSelectTab: (index: number) => void
interest: string
active: boolean
index: number
interestsDisplayName: string
onLayout: (index: number, x: number, width: number) => void
}): React.ReactNode => {
const t = useTheme()
const {_} = useLingui()
const activeText = active ? _(msg` (active)`) : ''
return (
<View
key={interest}
onLayout={e =>
onLayout(index, e.nativeEvent.layout.x, e.nativeEvent.layout.width)
}>
<Button
label={_(msg`Search for "${interestsDisplayName}"${activeText}`)}
onPress={() => onSelectTab(index)}>
{({hovered, pressed, focused}) => (
<View
style={[
a.rounded_full,
a.px_lg,
a.py_sm,
a.border,
active || hovered || pressed || focused
? [
t.atoms.bg_contrast_25,
{borderColor: t.atoms.bg_contrast_25.backgroundColor},
]
: [t.atoms.bg, t.atoms.border_contrast_low],
]}>
<Text
style={[
/* TODO: medium weight */
active || hovered || pressed || focused
? t.atoms.text
: t.atoms.text_contrast_medium,
]}>
{interestsDisplayName}
</Text>
</View>
)}
</Button>
</View>
)
}
Tab = memo(Tab)
/**
* Profile card for suggested accounts. Note: border is on the bottom edge
*/
let SuggestedProfileCard = ({
profile,
moderationOpts,
recId,
position,
}: {
profile: bsky.profile.AnyProfileView
moderationOpts: ModerationOpts
recId?: number
position: number
}): React.ReactNode => {
const t = useTheme()
return (
<ProfileCard.Link
profile={profile}
style={[a.flex_1]}
onPress={() => {
logger.metric(
'suggestedUser:press',
{
logContext: 'Explore',
recId,
position,
},
{statsig: true},
)
}}>
<View
style={[
a.w_full,
a.py_lg,
a.px_lg,
a.border_t,
t.atoms.border_contrast_low,
a.flex_1,
]}>
<ProfileCard.Outer>
<ProfileCard.Header>
<ProfileCard.Avatar
profile={profile}
moderationOpts={moderationOpts}
/>
<ProfileCard.NameAndHandle
profile={profile}
moderationOpts={moderationOpts}
/>
<ProfileCard.FollowButton
profile={profile}
moderationOpts={moderationOpts}
withIcon={false}
logContext="ExploreSuggestedAccounts"
onFollow={() => {
logger.metric(
'suggestedUser:follow',
{
logContext: 'Explore',
location: 'Card',
recId,
position,
},
{statsig: true},
)
}}
/>
</ProfileCard.Header>
<ProfileCard.Description profile={profile} numberOfLines={2} />
</ProfileCard.Outer>
</View>
</ProfileCard.Link>
)
}
SuggestedProfileCard = memo(SuggestedProfileCard)
export {SuggestedProfileCard}
@@ -0,0 +1,278 @@
import {Pressable, View} from 'react-native'
import {type AppBskyUnspeccedDefs} from '@atproto/api'
import {msg, plural, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {logger} from '#/logger'
import {useTrendingSettings} from '#/state/preferences/trending'
import {useGetTrendsQuery} from '#/state/queries/trending/useGetTrendsQuery'
import {useTrendingConfig} from '#/state/trending-config'
import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
import {formatCount} from '#/view/com/util/numeric/format'
import {atoms as a, useGutters, useTheme, type ViewStyleProp, web} from '#/alf'
import {AvatarStack} from '#/components/AvatarStack'
import {type Props as SVGIconProps} from '#/components/icons/common'
import {Flame_Stroke2_Corner1_Rounded as FlameIcon} from '#/components/icons/Flame'
import {Trending3_Stroke2_Corner1_Rounded as TrendingIcon} from '#/components/icons/Trending'
import {Link} from '#/components/Link'
import {Text} from '#/components/Typography'
const TOPIC_COUNT = 5
export function ExploreTrendingTopics() {
const {enabled} = useTrendingConfig()
const {trendingDisabled} = useTrendingSettings()
return enabled && !trendingDisabled ? <Inner /> : null
}
function Inner() {
const {data: trending, error, isLoading} = useGetTrendsQuery()
const noTopics = !isLoading && !error && !trending?.trends?.length
return isLoading ? (
Array.from({length: TOPIC_COUNT}).map((__, i) => (
<TrendingTopicRowSkeleton key={i} withPosts={i === 0} />
))
) : error || !trending?.trends || noTopics ? null : (
<>
{trending.trends.map((trend, index) => (
<TrendRow
key={trend.link}
trend={trend}
rank={index + 1}
onPress={() => {
logger.metric('trendingTopic:click', {context: 'explore'})
}}
/>
))}
</>
)
}
export function TrendRow({
trend,
rank,
children,
onPress,
}: ViewStyleProp & {
trend: AppBskyUnspeccedDefs.TrendView
rank: number
children?: React.ReactNode
onPress?: () => void
}) {
const t = useTheme()
const {_, i18n} = useLingui()
const gutters = useGutters([0, 'base'])
const category = useCategoryDisplayName(trend?.category || 'other')
const age = Math.floor(
(Date.now() - new Date(trend.startedAt || Date.now()).getTime()) /
(1000 * 60 * 60),
)
const badgeType = trend.status === 'hot' ? 'hot' : age < 2 ? 'new' : age
const postCount = trend.postCount
? _(
plural(trend.postCount, {
other: `${formatCount(i18n, trend.postCount)} posts`,
}),
)
: null
return (
<Link
testID={trend.link}
label={_(msg`Browse topic ${trend.displayName}`)}
to={trend.link}
onPress={onPress}
style={[a.border_b, t.atoms.border_contrast_low]}
PressableComponent={Pressable}>
{({hovered, pressed}) => (
<>
<View
style={[
gutters,
a.w_full,
a.py_lg,
a.flex_row,
a.gap_2xs,
(hovered || pressed) && t.atoms.bg_contrast_25,
]}>
<View style={[a.flex_1, a.gap_xs]}>
<View style={[a.flex_row]}>
<Text
style={[a.text_md, a.font_bold, a.leading_snug, {width: 20}]}>
<Trans comment='The trending topic rank, i.e. "1. March Madness", "2. The Bachelor"'>
{rank}.
</Trans>
</Text>
<Text
style={[a.text_md, a.font_bold, a.leading_snug]}
numberOfLines={1}>
{trend.displayName}
</Text>
</View>
<View
style={[
a.flex_row,
a.gap_sm,
a.align_center,
{paddingLeft: 20},
]}>
{trend.actors.length > 0 && (
<AvatarStack size={20} profiles={trend.actors} />
)}
<Text
style={[
a.text_sm,
t.atoms.text_contrast_medium,
web(a.leading_snug),
]}
numberOfLines={1}>
{postCount}
{postCount && category && <> &middot; </>}
{category}
</Text>
</View>
</View>
<View style={[a.flex_shrink_0]}>
<TrendingIndicator type={badgeType} />
</View>
</View>
{children}
</>
)}
</Link>
)
}
type TrendingIndicatorType = 'hot' | 'new' | number
function TrendingIndicator({type}: {type: TrendingIndicatorType | 'skeleton'}) {
const t = useTheme()
const {_} = useLingui()
const pillStyles = [
a.flex_row,
a.align_center,
a.gap_xs,
a.rounded_full,
a.px_sm,
{
height: 28,
},
]
let Icon: React.ComponentType<SVGIconProps> | null = null
let text: string | null = null
let color: string | null = null
let backgroundColor: string | null = null
switch (type) {
case 'skeleton': {
return (
<View
style={[
pillStyles,
{backgroundColor: t.palette.contrast_25, width: 65, height: 28},
]}
/>
)
}
case 'hot': {
Icon = FlameIcon
color =
t.scheme === 'light' ? t.palette.negative_500 : t.palette.negative_950
backgroundColor =
t.scheme === 'light' ? t.palette.negative_50 : t.palette.negative_200
text = _(msg`Hot`)
break
}
case 'new': {
Icon = TrendingIcon
text = _(msg`New`)
color = t.palette.positive_700
backgroundColor = t.palette.positive_50
break
}
default: {
text = _(
msg({
message: `${type}h ago`,
comment:
'trending topic time spent trending. should be as short as possible to fit in a pill',
}),
)
color = t.atoms.text_contrast_medium.color
backgroundColor = t.atoms.bg_contrast_25.backgroundColor
break
}
}
return (
<View style={[pillStyles, {backgroundColor}]}>
{Icon && <Icon size="sm" style={{color}} />}
<Text style={[a.text_sm, {color}]}>{text}</Text>
</View>
)
}
function useCategoryDisplayName(
category: AppBskyUnspeccedDefs.TrendView['category'],
) {
const {_} = useLingui()
switch (category) {
case 'sports':
return _(msg`Sports`)
case 'politics':
return _(msg`Politics`)
case 'video-games':
return _(msg`Video Games`)
case 'pop-culture':
return _(msg`Entertainment`)
case 'news':
return _(msg`News`)
case 'other':
default:
return null
}
}
export function TrendingTopicRowSkeleton({}: {withPosts: boolean}) {
const t = useTheme()
const gutters = useGutters([0, 'base'])
return (
<View
style={[
gutters,
a.w_full,
a.py_lg,
a.flex_row,
a.gap_2xs,
a.border_b,
t.atoms.border_contrast_low,
]}>
<View style={[a.flex_1, a.gap_sm]}>
<View style={[a.flex_row, a.align_center]}>
<View style={[{width: 20}]}>
<LoadingPlaceholder
width={12}
height={12}
style={[a.rounded_full]}
/>
</View>
<LoadingPlaceholder width={90} height={18} />
</View>
<View style={[a.flex_row, a.gap_sm, a.align_center, {paddingLeft: 20}]}>
<LoadingPlaceholder width={70} height={18} />
<LoadingPlaceholder width={40} height={18} />
<LoadingPlaceholder width={60} height={18} />
</View>
</View>
<View style={[a.flex_shrink_0]}>
<TrendingIndicator type="skeleton" />
</View>
</View>
)
}
@@ -1,4 +1,4 @@
import React from 'react'
import {useMemo} from 'react'
import {ScrollView, View} from 'react-native'
import {AppBskyEmbedVideo, AtUri} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
@@ -8,18 +8,12 @@ import {useQueryClient} from '@tanstack/react-query'
import {VIDEO_FEED_URI} from '#/lib/constants'
import {makeCustomFeedLink} from '#/lib/routes/links'
import {logEvent} from '#/lib/statsig/statsig'
import {isWeb} from '#/platform/detection'
import {useSavedFeeds} from '#/state/queries/feed'
import {logger} from '#/logger'
import {RQKEY, usePostFeedQuery} from '#/state/queries/post-feed'
import {useAddSavedFeedsMutation} from '#/state/queries/preferences'
import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture'
import {atoms as a, tokens, useGutters, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {GradientFill} from '#/components/GradientFill'
import {ButtonIcon} from '#/components/Button'
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
import {Pin_Stroke2_Corner0_Rounded as Pin} from '#/components/icons/Pin'
import {Trending2_Stroke2_Corner2_Rounded as Graph} from '#/components/icons/Trending2'
import {Link} from '#/components/Link'
import {Text} from '#/components/Typography'
import {
@@ -37,7 +31,6 @@ const FEED_PARAMS: {
}
export function ExploreTrendingVideos() {
const t = useTheme()
const {_} = useLingui()
const gutters = useGutters([0, 'base'])
const {data, isLoading, error} = usePostFeedQuery(FEED_DESC, FEED_PARAMS)
@@ -55,30 +48,30 @@ export function ExploreTrendingVideos() {
}
})
const {data: saved} = useSavedFeeds()
const isSavedAlready = React.useMemo(() => {
return !!saved?.feeds?.some(info => info.config.value === VIDEO_FEED_URI)
}, [saved])
// const {data: saved} = useSavedFeeds()
// const isSavedAlready = useMemo(() => {
// return !!saved?.feeds?.some(info => info.config.value === VIDEO_FEED_URI)
// }, [saved])
const {mutateAsync: addSavedFeeds, isPending: isPinPending} =
useAddSavedFeedsMutation()
const pinFeed = React.useCallback(
(e: any) => {
e.preventDefault()
// const {mutateAsync: addSavedFeeds, isPending: isPinPending} =
// useAddSavedFeedsMutation()
// const pinFeed = useCallback(
// (e: any) => {
// e.preventDefault()
addSavedFeeds([
{
type: 'feed',
value: VIDEO_FEED_URI,
pinned: true,
},
])
// addSavedFeeds([
// {
// type: 'feed',
// value: VIDEO_FEED_URI,
// pinned: true,
// },
// ])
// prevent navigation
return false
},
[addSavedFeeds],
)
// // prevent navigation
// return false
// },
// [addSavedFeeds],
// )
if (error) {
return null
@@ -86,38 +79,6 @@ export function ExploreTrendingVideos() {
return (
<View style={[a.pb_xl]}>
<View
style={[
a.flex_row,
isWeb
? [a.px_lg, a.py_lg, a.pt_2xl, a.gap_md]
: [a.p_lg, a.pt_xl, a.gap_md],
a.border_b,
t.atoms.border_contrast_low,
]}>
<View style={[a.flex_1, a.gap_sm]}>
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
<Graph
size="lg"
fill={t.palette.primary_500}
style={{marginLeft: -2}}
/>
<Text style={[a.text_2xl, a.font_heavy, t.atoms.text]}>
<Trans>Trending Videos</Trans>
</Text>
<View style={[a.py_xs, a.px_sm, a.rounded_sm, a.overflow_hidden]}>
<GradientFill gradient={tokens.gradients.primary} />
<Text style={[a.text_sm, a.font_heavy, {color: 'white'}]}>
<Trans>BETA</Trans>
</Text>
</View>
</View>
<Text style={[t.atoms.text_contrast_high, a.leading_snug]}>
<Trans>Popular videos in your network.</Trans>
</Text>
</View>
</View>
<BlockDrawerGesture>
<ScrollView
horizontal
@@ -153,7 +114,7 @@ export function ExploreTrendingVideos() {
</ScrollView>
</BlockDrawerGesture>
{!isSavedAlready && (
{/* {!isSavedAlready && (
<View
style={[
gutters,
@@ -179,7 +140,7 @@ export function ExploreTrendingVideos() {
<ButtonIcon icon={Pin} position="right" />
</Button>
</View>
)}
)} */}
</View>
)
}
@@ -191,7 +152,7 @@ function VideoCards({
}) {
const t = useTheme()
const {_} = useLingui()
const items = React.useMemo(() => {
const items = useMemo(() => {
return data.pages
.flatMap(page => page.slices)
.map(slice => slice.items[0])
@@ -199,7 +160,7 @@ function VideoCards({
.filter(item => AppBskyEmbedVideo.isView(item.post.embed))
.slice(0, 8)
}, [data])
const href = React.useMemo(() => {
const href = useMemo(() => {
const urip = new AtUri(VIDEO_FEED_URI)
return makeCustomFeedLink(urip.host, urip.rkey, undefined, 'explore')
}, [])
@@ -217,9 +178,11 @@ function VideoCards({
sourceInterstitial: 'explore',
}}
onInteract={() => {
logEvent('videoCard:click', {
context: 'interstitial:explore',
})
logger.metric(
'videoCard:click',
{context: 'interstitial:explore'},
{statsig: true},
)
}}
/>
</View>