Add Discover interstitial, settings, includes pin for now
This commit is contained in:
@@ -27,14 +27,23 @@ import {MediaInsetBorder} from '#/components/MediaInsetBorder'
|
||||
import * as Hider from '#/components/moderation/Hider'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
type DisplayVariant = 'default' | 'compact'
|
||||
|
||||
export function VideoPostCard({
|
||||
post,
|
||||
sourceContext,
|
||||
moderation,
|
||||
variant = 'default',
|
||||
onInteract,
|
||||
}: {
|
||||
post: AppBskyFeedDefs.PostView
|
||||
sourceContext: VideoFeedSourceContext
|
||||
moderation: ModerationDecision
|
||||
variant?: DisplayVariant
|
||||
/**
|
||||
* Callback for metrics and stuff
|
||||
*/
|
||||
onInteract: () => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_, i18n} = useLingui()
|
||||
@@ -51,6 +60,7 @@ export function VideoPostCard({
|
||||
*/
|
||||
if (!AppBskyEmbedVideo.isView(embed)) return null
|
||||
|
||||
const isCompact = variant === 'compact'
|
||||
const text = AppBskyFeedPost.isRecord(post.record) ? post.record?.text : ''
|
||||
const likeCount = post?.likeCount ?? 0
|
||||
const repostCount = post?.repostCount ?? 0
|
||||
@@ -71,6 +81,9 @@ export function VideoPostCard({
|
||||
initialPostUri: post.uri,
|
||||
},
|
||||
}}
|
||||
onPress={() => {
|
||||
onInteract()
|
||||
}}
|
||||
onPressIn={onPressIn}
|
||||
onPressOut={onPressOut}
|
||||
style={[
|
||||
@@ -169,7 +182,7 @@ export function VideoPostCard({
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{repostCount > 0 && (
|
||||
{!isCompact && repostCount > 0 && (
|
||||
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
|
||||
<Repost size="sm" fill="white" />
|
||||
<Text style={[a.text_sm, a.font_bold, {color: 'white'}]}>
|
||||
@@ -182,7 +195,7 @@ export function VideoPostCard({
|
||||
</View>
|
||||
</View>
|
||||
<View style={[a.pr_xs, {paddingTop: 6, gap: 4}]}>
|
||||
{text && (
|
||||
{!isCompact && text && (
|
||||
<Text style={[a.text_md, a.leading_snug]} numberOfLines={2} emoji>
|
||||
{text}
|
||||
</Text>
|
||||
@@ -207,7 +220,11 @@ export function VideoPostCard({
|
||||
)
|
||||
}
|
||||
|
||||
export function VideoPostCardPlaceholder() {
|
||||
export function VideoPostCardPlaceholder({
|
||||
variant = 'default',
|
||||
}: {
|
||||
variant?: DisplayVariant
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const black = select(t.name, {
|
||||
light: t.palette.black,
|
||||
@@ -228,42 +245,49 @@ export function VideoPostCardPlaceholder() {
|
||||
]}>
|
||||
<MediaInsetBorder />
|
||||
</View>
|
||||
<VideoPostCardTextPlaceholder />
|
||||
<VideoPostCardTextPlaceholder variant={variant} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function VideoPostCardTextPlaceholder({
|
||||
author,
|
||||
variant,
|
||||
}: {
|
||||
author?: AppBskyActorDefs.ProfileViewBasic
|
||||
variant?: DisplayVariant
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const isCompact = variant === 'compact'
|
||||
|
||||
return (
|
||||
<View style={[a.flex_1]}>
|
||||
<View style={[a.pr_xs, {paddingTop: 8, gap: 6}]}>
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
a.rounded_xs,
|
||||
t.atoms.bg_contrast_50,
|
||||
{
|
||||
height: 14,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
a.rounded_xs,
|
||||
t.atoms.bg_contrast_50,
|
||||
{
|
||||
height: 14,
|
||||
width: '70%',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{!isCompact && (
|
||||
<>
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
a.rounded_xs,
|
||||
t.atoms.bg_contrast_50,
|
||||
{
|
||||
height: 14,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
a.rounded_xs,
|
||||
t.atoms.bg_contrast_50,
|
||||
{
|
||||
height: 14,
|
||||
width: '70%',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{author ? (
|
||||
<View style={[a.flex_row, a.gap_xs, a.align_center]}>
|
||||
<UserAvatar type="user" size={20} avatar={author.avatar} />
|
||||
@@ -298,8 +322,9 @@ export function VideoPostCardTextPlaceholder({
|
||||
height: 12,
|
||||
width: '75%',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
]}>
|
||||
<MediaInsetBorder />
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import React from 'react'
|
||||
import {ScrollView,View} from 'react-native'
|
||||
import {AppBskyEmbedVideo} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {VIDEO_FEED_URI} from '#/lib/constants'
|
||||
import {logEvent} from '#/lib/statsig/statsig'
|
||||
import {useTrendingSettingsApi} from '#/state/preferences/trending'
|
||||
import {useSavedFeeds} from '#/state/queries/feed'
|
||||
import {usePostFeedQuery} from '#/state/queries/post-feed'
|
||||
import {useAddSavedFeedsMutation} from '#/state/queries/preferences'
|
||||
import {atoms as a, useGutters,useTheme} from '#/alf'
|
||||
import {Button, ButtonIcon,ButtonText} from '#/components/Button'
|
||||
import {Divider} from '#/components/Divider'
|
||||
import {Pin_Stroke2_Corner0_Rounded as Pin} from '#/components/icons/Pin'
|
||||
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
|
||||
import {Trending2_Stroke2_Corner2_Rounded as Graph} from '#/components/icons/Trending2'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {
|
||||
VideoPostCard,
|
||||
VideoPostCardPlaceholder,
|
||||
} from '#/components/VideoPostCard'
|
||||
|
||||
const CARD_WIDTH = 100
|
||||
|
||||
export function TrendingVideos() {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const gutters = useGutters([0, 'base'])
|
||||
const {data, isLoading, error} = usePostFeedQuery(`feedgen|${VIDEO_FEED_URI}`)
|
||||
const {setTrendingVideoDisabled} = useTrendingSettingsApi()
|
||||
|
||||
const {data: saved} = useSavedFeeds()
|
||||
const isSavedAlready = React.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()
|
||||
|
||||
addSavedFeeds([
|
||||
{
|
||||
type: 'feed',
|
||||
value: VIDEO_FEED_URI,
|
||||
pinned: true,
|
||||
},
|
||||
])
|
||||
|
||||
// prevent navigation
|
||||
return false
|
||||
},
|
||||
[addSavedFeeds],
|
||||
)
|
||||
|
||||
const hide = React.useCallback(() => {
|
||||
setTrendingVideoDisabled(true)
|
||||
logEvent('trendingVideos:hide', {context: 'interstitial'})
|
||||
}, [setTrendingVideoDisabled])
|
||||
|
||||
if (error) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.pt_lg,
|
||||
a.pb_md,
|
||||
a.border_t,
|
||||
t.atoms.border_contrast_low,
|
||||
t.atoms.bg_contrast_25,
|
||||
]}>
|
||||
<View
|
||||
style={[
|
||||
gutters,
|
||||
a.pb_sm,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.justify_between,
|
||||
]}>
|
||||
<View style={[a.flex_1, a.flex_row, a.align_center, a.gap_xs]}>
|
||||
<Graph />
|
||||
<Text style={[a.text_md, a.font_bold, a.leading_snug]}>
|
||||
<Trans>Trending Videos</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
<Button
|
||||
label={_(msg`Dismiss this section`)}
|
||||
size="tiny"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
shape="round"
|
||||
onPress={hide}>
|
||||
<ButtonIcon icon={X} />
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
snapToInterval={(CARD_WIDTH + a.gap_sm.gap) * 2}>
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.gap_sm,
|
||||
a.pb_sm,
|
||||
{
|
||||
paddingLeft: gutters.paddingLeft,
|
||||
paddingRight: gutters.paddingRight,
|
||||
},
|
||||
]}>
|
||||
{isLoading ? (
|
||||
Array(10)
|
||||
.fill(0)
|
||||
.map((_, i) => (
|
||||
<View key={i} style={[{width: CARD_WIDTH}]}>
|
||||
<VideoPostCardPlaceholder variant="compact" />
|
||||
</View>
|
||||
))
|
||||
) : error || !data ? (
|
||||
<Text>
|
||||
<Trans>Whoops! Trending videos failed to load.</Trans>
|
||||
</Text>
|
||||
) : (
|
||||
data.pages
|
||||
.flatMap(page => page.slices)
|
||||
.map(slice => slice.items[0])
|
||||
.filter(Boolean)
|
||||
.filter(item => AppBskyEmbedVideo.isView(item.post.embed))
|
||||
.map(item => (
|
||||
<View key={item.post.uri} style={[{width: CARD_WIDTH}]}>
|
||||
<VideoPostCard
|
||||
variant="compact"
|
||||
post={item.post}
|
||||
moderation={item.moderation}
|
||||
sourceContext={{
|
||||
type: 'feedgen',
|
||||
uri: VIDEO_FEED_URI,
|
||||
}}
|
||||
onInteract={() => {
|
||||
logEvent('trendingVideo:click', {context: 'interstitial'})
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
{!isSavedAlready && (
|
||||
<View style={[gutters, a.pt_xs]}>
|
||||
<Divider />
|
||||
|
||||
<View
|
||||
style={[
|
||||
a.pt_sm,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.justify_between,
|
||||
a.gap_md,
|
||||
]}>
|
||||
<Text style={[a.flex_1, a.text_sm, a.leading_snug]}>
|
||||
<Trans>Pin to your home screen for easy access</Trans>
|
||||
</Text>
|
||||
<Button
|
||||
disabled={isPinPending}
|
||||
label={_(msg`Pin`)}
|
||||
size="small"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onPress={pinFeed}>
|
||||
<ButtonText>{_(msg`Pin`)}</ButtonText>
|
||||
<ButtonIcon icon={Pin} position="right" />
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -249,6 +249,15 @@ export type LogEvents = {
|
||||
'recommendedTopic:click': {
|
||||
context: 'explore'
|
||||
}
|
||||
'trendingVideos:show': {
|
||||
context: 'settings'
|
||||
}
|
||||
'trendingVideos:hide': {
|
||||
context: 'settings' | 'interstitial' | 'explore'
|
||||
}
|
||||
'trendingVideo:click': {
|
||||
context: 'interstitial' | 'explore'
|
||||
}
|
||||
|
||||
'progressGuide:hide': {}
|
||||
'progressGuide:followDialog:open': {}
|
||||
|
||||
@@ -37,8 +37,9 @@ export function ContentAndMediaSettingsScreen({}: Props) {
|
||||
const inAppBrowserPref = useInAppBrowser()
|
||||
const setUseInAppBrowser = useSetInAppBrowser()
|
||||
const {enabled: trendingEnabled} = useTrendingConfig()
|
||||
const {trendingDisabled} = useTrendingSettings()
|
||||
const {setTrendingDisabled} = useTrendingSettingsApi()
|
||||
const {trendingDisabled, trendingVideoDisabled} = useTrendingSettings()
|
||||
const {setTrendingDisabled, setTrendingVideoDisabled} =
|
||||
useTrendingSettingsApi()
|
||||
|
||||
return (
|
||||
<Layout.Screen>
|
||||
@@ -138,6 +139,27 @@ export function ContentAndMediaSettingsScreen({}: Props) {
|
||||
<Toggle.Platform />
|
||||
</SettingsList.Item>
|
||||
</Toggle.Item>
|
||||
<Toggle.Item
|
||||
name="show_trending_videos"
|
||||
label={_(msg`Enable trending videos`)}
|
||||
value={!trendingVideoDisabled}
|
||||
onChange={value => {
|
||||
const hide = Boolean(!value)
|
||||
if (hide) {
|
||||
logEvent('trendingVideos:hide', {context: 'settings'})
|
||||
} else {
|
||||
logEvent('trendingVideos:show', {context: 'settings'})
|
||||
}
|
||||
setTrendingVideoDisabled(hide)
|
||||
}}>
|
||||
<SettingsList.Item>
|
||||
<SettingsList.ItemIcon icon={Graph} />
|
||||
<SettingsList.ItemText>
|
||||
<Trans>Enable trending videos</Trans>
|
||||
</SettingsList.ItemText>
|
||||
<Toggle.Platform />
|
||||
</SettingsList.Item>
|
||||
</Toggle.Item>
|
||||
</>
|
||||
)}
|
||||
</SettingsList.Container>
|
||||
|
||||
@@ -126,6 +126,7 @@ const schema = z.object({
|
||||
/** @deprecated */
|
||||
mutedThreads: z.array(z.string()),
|
||||
trendingDisabled: z.boolean().optional(),
|
||||
trendingVideoDisabled: z.boolean().optional(),
|
||||
})
|
||||
export type Schema = z.infer<typeof schema>
|
||||
|
||||
@@ -172,6 +173,7 @@ export const defaults: Schema = {
|
||||
hasCheckedForStarterPack: false,
|
||||
subtitlesEnabled: true,
|
||||
trendingDisabled: false,
|
||||
trendingVideoDisabled: false,
|
||||
}
|
||||
|
||||
export function tryParse(rawData: string): Schema | undefined {
|
||||
|
||||
@@ -4,18 +4,27 @@ import * as persisted from '#/state/persisted'
|
||||
|
||||
type StateContext = {
|
||||
trendingDisabled: Exclude<persisted.Schema['trendingDisabled'], undefined>
|
||||
trendingVideoDisabled: Exclude<
|
||||
persisted.Schema['trendingVideoDisabled'],
|
||||
undefined
|
||||
>
|
||||
}
|
||||
type ApiContext = {
|
||||
setTrendingDisabled(
|
||||
hidden: Exclude<persisted.Schema['trendingDisabled'], undefined>,
|
||||
): void
|
||||
setTrendingVideoDisabled(
|
||||
hidden: Exclude<persisted.Schema['trendingVideoDisabled'], undefined>,
|
||||
): void
|
||||
}
|
||||
|
||||
const StateContext = React.createContext<StateContext>({
|
||||
trendingDisabled: Boolean(persisted.defaults.trendingDisabled),
|
||||
trendingVideoDisabled: Boolean(persisted.defaults.trendingVideoDisabled),
|
||||
})
|
||||
const ApiContext = React.createContext<ApiContext>({
|
||||
setTrendingDisabled() {},
|
||||
setTrendingVideoDisabled() {},
|
||||
})
|
||||
|
||||
function usePersistedBooleanValue<T extends keyof persisted.Schema>(key: T) {
|
||||
@@ -43,14 +52,19 @@ function usePersistedBooleanValue<T extends keyof persisted.Schema>(key: T) {
|
||||
export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
const [trendingDisabled, setTrendingDisabled] =
|
||||
usePersistedBooleanValue('trendingDisabled')
|
||||
const [trendingVideoDisabled, setTrendingVideoDisabled] =
|
||||
usePersistedBooleanValue('trendingVideoDisabled')
|
||||
|
||||
/*
|
||||
* Context
|
||||
*/
|
||||
const state = React.useMemo(() => ({trendingDisabled}), [trendingDisabled])
|
||||
const state = React.useMemo(
|
||||
() => ({trendingDisabled, trendingVideoDisabled}),
|
||||
[trendingDisabled, trendingVideoDisabled],
|
||||
)
|
||||
const api = React.useMemo(
|
||||
() => ({setTrendingDisabled}),
|
||||
[setTrendingDisabled],
|
||||
() => ({setTrendingDisabled, setTrendingVideoDisabled}),
|
||||
[setTrendingDisabled, setTrendingVideoDisabled],
|
||||
)
|
||||
|
||||
return (
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
PostFeedVideoGridRowPlaceholder,
|
||||
} from '#/components/feeds/PostFeedVideoGridRow'
|
||||
import {TrendingInterstitial} from '#/components/interstitials/Trending'
|
||||
import {TrendingVideos as TrendingVideosInterstitial} from '#/components/interstitials/TrendingVideos'
|
||||
import {DiscoverFallbackHeader} from './DiscoverFallbackHeader'
|
||||
import {FeedShutdownMsg} from './FeedShutdownMsg'
|
||||
import {PostFeedErrorMessage} from './PostFeedErrorMessage'
|
||||
@@ -116,6 +117,10 @@ type FeedRow =
|
||||
type: 'interstitialTrending'
|
||||
key: string
|
||||
}
|
||||
| {
|
||||
type: 'interstitialTrendingVideos'
|
||||
key: string
|
||||
}
|
||||
|
||||
export function getFeedPostSlice(feedRow: FeedRow): FeedPostSlice | null {
|
||||
if (feedRow.type === 'sliceItem') {
|
||||
@@ -287,7 +292,7 @@ let PostFeed = ({
|
||||
const showProgressIntersitial =
|
||||
(followProgressGuide || followAndLikeProgressGuide) && !isDesktop
|
||||
|
||||
const {trendingDisabled} = useTrendingSettings()
|
||||
const {trendingDisabled, trendingVideoDisabled} = useTrendingSettings()
|
||||
|
||||
const feedItems: FeedRow[] = React.useMemo(() => {
|
||||
let feedKind: 'following' | 'discover' | 'profile' | 'thevids' | undefined
|
||||
@@ -380,6 +385,13 @@ let PostFeed = ({
|
||||
'interstitial2-' + sliceIndex + '-' + lastFetchedAt,
|
||||
})
|
||||
}
|
||||
} else if (sliceIndex === 15) {
|
||||
if (isNative && !trendingVideoDisabled) {
|
||||
arr.push({
|
||||
type: 'interstitialTrendingVideos',
|
||||
key: 'interstitial-' + sliceIndex + '-' + lastFetchedAt,
|
||||
})
|
||||
}
|
||||
} else if (sliceIndex === 30) {
|
||||
arr.push({
|
||||
type: 'interstitialFollows',
|
||||
@@ -475,6 +487,7 @@ let PostFeed = ({
|
||||
hasSession,
|
||||
showProgressIntersitial,
|
||||
trendingDisabled,
|
||||
trendingVideoDisabled,
|
||||
gtTablet,
|
||||
gtMobile,
|
||||
isVideoFeed,
|
||||
@@ -566,6 +579,8 @@ let PostFeed = ({
|
||||
return <ProgressGuide />
|
||||
} else if (row.type === 'interstitialTrending') {
|
||||
return <TrendingInterstitial />
|
||||
} else if (row.type === 'interstitialTrendingVideos') {
|
||||
return <TrendingVideosInterstitial />
|
||||
} else if (row.type === 'sliceItem') {
|
||||
const slice = row.slice
|
||||
if (slice.isFallbackMarker) {
|
||||
|
||||
Reference in New Issue
Block a user