Add feed banner

This commit is contained in:
Eric Bailey
2025-07-16 16:46:28 -05:00
parent 1dbc331314
commit 75df582944
5 changed files with 183 additions and 96 deletions
@@ -0,0 +1,132 @@
import {useMemo} from 'react'
import {View} from 'react-native'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance'
import {Nux, useNux, useSaveNux} from '#/state/queries/nuxs'
import {atoms as a, select, useTheme} from '#/alf'
import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy'
import {Button} from '#/components/Button'
import {ShieldCheck_Stroke2_Corner0_Rounded as Shield} from '#/components/icons/Shield'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import {Link} from '#/components/Link'
import {Text} from '#/components/Typography'
export function useInternalState() {
const {isReady, isDeclaredUnderage, isAgeRestricted, lastInitiatedAt} =
useAgeAssurance()
const {nux} = useNux(Nux.AgeAssuranceDismissibleHeaderButton)
const {mutate: save, variables} = useSaveNux()
const hidden = !!variables
const visible = useMemo(() => {
if (!isReady) return false
if (isDeclaredUnderage) return false
if (!isAgeRestricted) return false
if (lastInitiatedAt) return false
if (hidden) return false
if (nux && nux.completed) return false
return true
}, [
isReady,
isDeclaredUnderage,
isAgeRestricted,
lastInitiatedAt,
hidden,
nux,
])
const close = () => {
save({
id: Nux.AgeAssuranceDismissibleHeaderButton,
completed: true,
data: undefined,
})
}
return {visible, close}
}
export function AgeAssuranceDismissibleFeedBanner() {
const t = useTheme()
const {_} = useLingui()
const {visible, close} = useInternalState()
const copy = useAgeAssuranceCopy()
if (!visible) return null
return (
<View
style={[
a.px_lg,
{
paddingVertical: 10,
backgroundColor: select(t.name, {
light: t.palette.primary_25,
dark: t.palette.primary_25,
dim: t.palette.primary_25,
}),
},
]}>
<Link
label={_(msg`Learn more about age assurance`)}
to="/settings/account"
onPress={close}
style={[a.w_full, a.justify_between, a.align_center, a.gap_md]}>
<View
style={[
a.align_center,
a.justify_center,
a.rounded_full,
{
width: 42,
height: 42,
backgroundColor: select(t.name, {
light: t.palette.primary_100,
dark: t.palette.primary_100,
dim: t.palette.primary_100,
}),
},
]}>
<Shield size="lg" />
</View>
<View
style={[
a.flex_1,
{
paddingRight: 40,
},
]}>
<Text style={[a.leading_snug]}>{copy.banner}</Text>
</View>
</Link>
<Button
label={_(msg`Don't show again`)}
size="small"
onPress={close}
style={[
a.absolute,
a.justify_center,
a.align_center,
{
top: 0,
bottom: 0,
right: 0,
paddingRight: a.px_md.paddingLeft,
},
]}>
<X
width={20}
fill={select(t.name, {
light: t.palette.primary_600,
dark: t.palette.primary_600,
dim: t.palette.primary_600,
})}
/>
</Button>
</View>
)
}
@@ -1,95 +0,0 @@
import {useMemo} from 'react'
import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance'
import {Nux, useNux, useSaveNux} from '#/state/queries/nuxs'
import {atoms as a, select, useTheme} from '#/alf'
import {ShieldCheck_Stroke2_Corner0_Rounded as Shield} from '#/components/icons/Shield'
import {Link} from '#/components/Link'
import {Text} from '#/components/Typography'
export function useInternalState() {
const {isReady, isDeclaredUnderage, isAgeRestricted, lastInitiatedAt} =
useAgeAssurance()
const {nux} = useNux(Nux.AgeAssuranceDismissibleHeaderButton)
const {mutate: save, variables} = useSaveNux()
const hidden = !!variables
const visible = useMemo(() => {
if (!isReady) return false
if (isDeclaredUnderage) return false
if (!isAgeRestricted) return false
if (lastInitiatedAt) return false
if (hidden) return false
if (nux && nux.completed) return false
return true
}, [
isReady,
isDeclaredUnderage,
isAgeRestricted,
lastInitiatedAt,
hidden,
nux,
])
const close = () => {
save({
id: Nux.AgeAssuranceDismissibleHeaderButton,
completed: true,
data: undefined,
})
}
return {visible, close}
}
export function AgeAssuranceDismissibleHeaderButton() {
const t = useTheme()
const {_} = useLingui()
const {visible, close} = useInternalState()
if (!visible) return null
return (
<Link
label={_(msg`Learn more about age assurance`)}
to="/settings/account"
onPress={close}>
<View
style={[
a.flex_row,
a.align_center,
a.gap_xs,
a.px_sm,
a.pr_sm,
a.rounded_full,
{
paddingVertical: 6,
backgroundColor: select(t.name, {
light: t.palette.primary_100,
dark: t.palette.primary_100,
dim: t.palette.primary_100,
}),
},
]}>
<Shield size="sm" />
<Text
style={[
a.font_bold,
a.leading_snug,
{
color: select(t.name, {
light: t.palette.primary_800,
dark: t.palette.primary_800,
dim: t.palette.primary_800,
}),
},
]}>
<Trans>Age Assurance</Trans>
</Text>
</View>
</Link>
)
}
@@ -10,6 +10,9 @@ export function useAgeAssuranceCopy() {
notice: _(
msg`The laws in your location require that you verify your age before accessing certain features on Bluesky like adult content and direct messaging.`,
),
banner: _(
msg`The laws in your location require you verify your age. Tap to learn more.`,
),
chatsInfoText: _(
msg`Don't worry! All existing messages and settings are saved and will be available after you've been verified to be 18 or older.`,
),
@@ -8,6 +8,7 @@ export function useIsAgeAssuranceEnabled() {
const {geolocation} = useGeolocation()
return useMemo(() => {
return gate('age_assurance') && !!geolocation?.isAgeRestrictedGeo
const enabled = __DEV__ || gate('age_assurance')
return enabled && !!geolocation?.isAgeRestrictedGeo
}, [geolocation, gate])
}
+46
View File
@@ -43,11 +43,16 @@ import {
import {useLiveNowConfig} from '#/state/service-config'
import {useSession} from '#/state/session'
import {useProgressGuide} from '#/state/shell/progress-guide'
import {useSelectedFeed} from '#/state/shell/selected-feed'
import {List, type ListRef} from '#/view/com/util/List'
import {PostFeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
import {LoadMoreRetryBtn} from '#/view/com/util/LoadMoreRetryBtn'
import {type VideoFeedSourceContext} from '#/screens/VideoFeed/types'
import {useBreakpoints, useLayoutBreakpoints} from '#/alf'
import {
AgeAssuranceDismissibleFeedBanner,
useInternalState as useAgeAssuranceBannerState,
} from '#/components/ageAssurance/AgeAssuranceDismissibleFeedBanner'
import {ProgressGuide, SuggestedFollows} from '#/components/FeedInterstitials'
import {
PostFeedVideoGridRow,
@@ -131,6 +136,10 @@ type FeedRow =
type: 'showLessFollowup'
key: string
}
| {
type: 'ageAssuranceBanner'
key: string
}
export function getItemsForFeedback(feedRow: FeedRow): {
item: FeedPostSliceItem
@@ -335,6 +344,14 @@ let PostFeed = ({
const {trendingDisabled, trendingVideoDisabled} = useTrendingSettings()
const ageAssuranceBannerState = useAgeAssuranceBannerState()
const selectedFeed = useSelectedFeed()
/**
* Cached value of whether the current feed was selected at startup. We don't
* want this to update when user swipes.
*/
const isCurrentFeedAtStartupSelected = useRef(selectedFeed === feed)
const feedItems: FeedRow[] = useMemo(() => {
// wraps a slice item, and replaces it with a showLessFollowup item
// if the user has pressed show less on it
@@ -450,6 +467,21 @@ let PostFeed = ({
type: 'interstitialProgressGuide',
key: 'interstitial-' + sliceIndex + '-' + lastFetchedAt,
})
} else {
/*
* Only insert if Discover was the last selected feed at
* startup, the progress guide isn't shown, and the
* banner is eligible to be shown.
*/
if (
isCurrentFeedAtStartupSelected.current &&
ageAssuranceBannerState.visible
) {
arr.push({
type: 'ageAssuranceBanner',
key: 'ageAssuranceBanner-' + sliceIndex,
})
}
}
if (!rightNavVisible && !trendingDisabled) {
arr.push({
@@ -478,6 +510,17 @@ let PostFeed = ({
key: 'interstitial-' + sliceIndex + '-' + lastFetchedAt,
})
}
} else {
/*
* Only insert if this feed was the last selected feed at
* startup and the banner is eligible to be shown.
*/
if (isCurrentFeedAtStartupSelected.current) {
arr.push({
type: 'ageAssuranceBanner',
key: 'ageAssuranceBanner-' + sliceIndex,
})
}
}
}
@@ -580,6 +623,7 @@ let PostFeed = ({
isVideoFeed,
areVideoFeedsEnabled,
hasPressedShowLessUris,
ageAssuranceBannerState,
])
// events
@@ -666,6 +710,8 @@ let PostFeed = ({
return <SuggestedFollows feed={feed} />
} else if (row.type === 'interstitialProgressGuide') {
return <ProgressGuide />
} else if (row.type === 'ageAssuranceBanner') {
return <AgeAssuranceDismissibleFeedBanner />
} else if (row.type === 'interstitialTrending') {
return <TrendingInterstitial />
} else if (row.type === 'interstitialTrendingVideos') {