From 75df5829447ae3b0f7a4cf1469be129ccc2d4b39 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Wed, 16 Jul 2025 16:46:28 -0500 Subject: [PATCH] Add feed banner --- .../AgeAssuranceDismissibleFeedBanner.tsx | 132 ++++++++++++++++++ .../AgeAssuranceDismissibleHeaderButton.tsx | 95 ------------- .../ageAssurance/useAgeAssuranceCopy.ts | 3 + .../ageAssurance/useIsAgeAssuranceEnabled.ts | 3 +- src/view/com/posts/PostFeed.tsx | 46 ++++++ 5 files changed, 183 insertions(+), 96 deletions(-) create mode 100644 src/components/ageAssurance/AgeAssuranceDismissibleFeedBanner.tsx delete mode 100644 src/components/ageAssurance/AgeAssuranceDismissibleHeaderButton.tsx diff --git a/src/components/ageAssurance/AgeAssuranceDismissibleFeedBanner.tsx b/src/components/ageAssurance/AgeAssuranceDismissibleFeedBanner.tsx new file mode 100644 index 0000000000..a25b26947c --- /dev/null +++ b/src/components/ageAssurance/AgeAssuranceDismissibleFeedBanner.tsx @@ -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 ( + + + + + + + + {copy.banner} + + + + + + ) +} diff --git a/src/components/ageAssurance/AgeAssuranceDismissibleHeaderButton.tsx b/src/components/ageAssurance/AgeAssuranceDismissibleHeaderButton.tsx deleted file mode 100644 index b6505fb0e0..0000000000 --- a/src/components/ageAssurance/AgeAssuranceDismissibleHeaderButton.tsx +++ /dev/null @@ -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 ( - - - - - Age Assurance - - - - ) -} diff --git a/src/components/ageAssurance/useAgeAssuranceCopy.ts b/src/components/ageAssurance/useAgeAssuranceCopy.ts index 045806994e..f3f96cd5c6 100644 --- a/src/components/ageAssurance/useAgeAssuranceCopy.ts +++ b/src/components/ageAssurance/useAgeAssuranceCopy.ts @@ -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.`, ), diff --git a/src/state/ageAssurance/useIsAgeAssuranceEnabled.ts b/src/state/ageAssurance/useIsAgeAssuranceEnabled.ts index 5c1a7b1c48..1a9c252dde 100644 --- a/src/state/ageAssurance/useIsAgeAssuranceEnabled.ts +++ b/src/state/ageAssurance/useIsAgeAssuranceEnabled.ts @@ -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]) } diff --git a/src/view/com/posts/PostFeed.tsx b/src/view/com/posts/PostFeed.tsx index 1d0649b2ee..a0128e139d 100644 --- a/src/view/com/posts/PostFeed.tsx +++ b/src/view/com/posts/PostFeed.tsx @@ -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 } else if (row.type === 'interstitialProgressGuide') { return + } else if (row.type === 'ageAssuranceBanner') { + return } else if (row.type === 'interstitialTrending') { return } else if (row.type === 'interstitialTrendingVideos') {