[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
@@ -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,
]),
}
}
@@ -0,0 +1,123 @@
import {View} from 'react-native'
import {type AppBskyUnspeccedDefs} from '@atproto/api'
import {Trans} from '@lingui/macro'
import {logger} from '#/logger'
import {isWeb} from '#/platform/detection'
import {
DEFAULT_LIMIT as RECOMMENDATIONS_COUNT,
useTrendingTopics,
} from '#/state/queries/trending/useTrendingTopics'
import {useTrendingConfig} from '#/state/trending-config'
import {atoms as a, useGutters, useTheme} from '#/alf'
import {Hashtag_Stroke2_Corner0_Rounded} from '#/components/icons/Hashtag'
import {
TrendingTopic,
TrendingTopicLink,
TrendingTopicSkeleton,
} 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
}
function Inner() {
const t = useTheme()
const gutters = useGutters([0, 'compact'])
const {data: trending, error, isLoading} = useTrendingTopics()
const noRecs = !isLoading && !error && !trending?.suggested?.length
const allFeeds = trending?.suggested && isAllFeeds(trending.suggested)
return error || noRecs ? 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]}>
<Hashtag_Stroke2_Corner0_Rounded
size="lg"
fill={t.palette.primary_500}
style={{marginLeft: -2}}
/>
<Text style={[a.text_2xl, a.font_heavy, t.atoms.text]}>
<Trans>Recommended</Trans>
</Text>
</View>
{!allFeeds ? (
<Text style={[t.atoms.text_contrast_high, a.leading_snug]}>
<Trans>
Content from across the network we think you might like.
</Trans>
</Text>
) : (
<Text style={[t.atoms.text_contrast_high, a.leading_snug]}>
<Trans>Feeds we think you might like.</Trans>
</Text>
)}
</View>
</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(RECOMMENDATIONS_COUNT)
.fill(0)
.map((_, i) => <TrendingTopicSkeleton key={i} index={i} />)
) : !trending?.suggested ? null : (
<>
{trending.suggested.map(topic => (
<TrendingTopicLink
key={topic.link}
topic={topic}
onPress={() => {
logger.metric(
'recommendedTopic:click',
{context: 'explore'},
{statsig: true},
)
}}>
{({hovered}) => (
<TrendingTopic
topic={topic}
style={[
hovered && [
t.atoms.border_contrast_high,
t.atoms.bg_contrast_25,
],
]}
/>
)}
</TrendingTopicLink>
))}
</>
)}
</View>
</View>
</>
)
}
function isAllFeeds(topics: AppBskyUnspeccedDefs.TrendingTopic[]) {
return topics.every(topic => {
const segments = topic.link.split('/').slice(1)
return segments[0] === 'profile' && segments[2] === 'feed'
})
}
@@ -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>
)
}
@@ -0,0 +1,234 @@
import {useMemo} from 'react'
import {ScrollView, View} from 'react-native'
import {AppBskyEmbedVideo, AtUri} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useFocusEffect} from '@react-navigation/native'
import {useQueryClient} from '@tanstack/react-query'
import {VIDEO_FEED_URI} from '#/lib/constants'
import {makeCustomFeedLink} from '#/lib/routes/links'
import {logger} from '#/logger'
import {RQKEY, usePostFeedQuery} from '#/state/queries/post-feed'
import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture'
import {atoms as a, tokens, useGutters, useTheme} from '#/alf'
import {ButtonIcon} from '#/components/Button'
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
import {Link} from '#/components/Link'
import {Text} from '#/components/Typography'
import {
CompactVideoPostCard,
CompactVideoPostCardPlaceholder,
} from '#/components/VideoPostCard'
const CARD_WIDTH = 100
const FEED_DESC = `feedgen|${VIDEO_FEED_URI}`
const FEED_PARAMS: {
feedCacheKey: 'explore'
} = {
feedCacheKey: 'explore',
}
export function ExploreTrendingVideos() {
const {_} = useLingui()
const gutters = useGutters([0, 'base'])
const {data, isLoading, error} = usePostFeedQuery(FEED_DESC, FEED_PARAMS)
// Refetch on tab change if nothing else is using this query.
const queryClient = useQueryClient()
useFocusEffect(() => {
return () => {
const query = queryClient
.getQueryCache()
.find({queryKey: RQKEY(FEED_DESC, FEED_PARAMS)})
if (query && query.getObserversCount() <= 1) {
query.fetch()
}
}
})
// 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 = useCallback(
// (e: any) => {
// e.preventDefault()
// addSavedFeeds([
// {
// type: 'feed',
// value: VIDEO_FEED_URI,
// pinned: true,
// },
// ])
// // prevent navigation
// return false
// },
// [addSavedFeeds],
// )
if (error) {
return null
}
return (
<View style={[a.pb_xl]}>
<BlockDrawerGesture>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
decelerationRate="fast"
snapToInterval={CARD_WIDTH + tokens.space.sm}>
<View
style={[
a.pt_lg,
a.flex_row,
a.gap_sm,
{
paddingLeft: gutters.paddingLeft,
paddingRight: gutters.paddingRight,
},
]}>
{isLoading ? (
Array(10)
.fill(0)
.map((_, i) => (
<View key={i} style={[{width: CARD_WIDTH}]}>
<CompactVideoPostCardPlaceholder />
</View>
))
) : error || !data ? (
<Text>
<Trans>Whoops! Trending videos failed to load.</Trans>
</Text>
) : (
<VideoCards data={data} />
)}
</View>
</ScrollView>
</BlockDrawerGesture>
{/* {!isSavedAlready && (
<View
style={[
gutters,
a.pt_lg,
a.flex_row,
a.align_center,
a.justify_between,
a.gap_xl,
]}>
<Text style={[a.flex_1, a.text_sm, a.leading_snug]}>
<Trans>
Pin the trending videos feed to your home screen for easy access
</Trans>
</Text>
<Button
disabled={isPinPending}
label={_(msg`Pin`)}
size="small"
variant="outline"
color="secondary"
onPress={pinFeed}>
<ButtonText>{_(msg`Pin`)}</ButtonText>
<ButtonIcon icon={Pin} position="right" />
</Button>
</View>
)} */}
</View>
)
}
function VideoCards({
data,
}: {
data: Exclude<ReturnType<typeof usePostFeedQuery>['data'], undefined>
}) {
const t = useTheme()
const {_} = useLingui()
const items = useMemo(() => {
return data.pages
.flatMap(page => page.slices)
.map(slice => slice.items[0])
.filter(Boolean)
.filter(item => AppBskyEmbedVideo.isView(item.post.embed))
.slice(0, 8)
}, [data])
const href = useMemo(() => {
const urip = new AtUri(VIDEO_FEED_URI)
return makeCustomFeedLink(urip.host, urip.rkey, undefined, 'explore')
}, [])
return (
<>
{items.map(item => (
<View key={item.post.uri} style={[{width: CARD_WIDTH}]}>
<CompactVideoPostCard
post={item.post}
moderation={item.moderation}
sourceContext={{
type: 'feedgen',
uri: VIDEO_FEED_URI,
sourceInterstitial: 'explore',
}}
onInteract={() => {
logger.metric(
'videoCard:click',
{context: 'interstitial:explore'},
{statsig: true},
)
}}
/>
</View>
))}
<View style={[{width: CARD_WIDTH * 2}]}>
<Link
to={href}
label={_(msg`View more`)}
style={[
a.justify_center,
a.align_center,
a.flex_1,
a.rounded_md,
t.atoms.bg_contrast_25,
]}>
{({pressed}) => (
<View
style={[
a.flex_row,
a.align_center,
a.gap_md,
{
opacity: pressed ? 0.6 : 1,
},
]}>
<Text style={[a.text_md]}>
<Trans>View more</Trans>
</Text>
<View
style={[
a.align_center,
a.justify_center,
a.rounded_full,
{
width: 34,
height: 34,
backgroundColor: t.palette.primary_500,
},
]}>
<ButtonIcon icon={ChevronRight} />
</View>
</View>
)}
</Link>
</View>
</>
)
}