diff options
author | Eric Bailey <git@esb.lol> | 2025-07-16 13:58:07 -0500 |
---|---|---|
committer | GitHub <noreply@github.com> | 2025-07-16 13:58:07 -0500 |
commit | 1dbc331314278cb7a42ded9b190dac7038ad9878 (patch) | |
tree | b5d44e1ea75ea9d5343eec90425c8c7ac74df39f /src | |
parent | 712c3ad4211e2e68d0cdbcc480967c63aeaa6c0e (diff) | |
download | voidsky-1dbc331314278cb7a42ded9b190dac7038ad9878.tar.zst |
UI for age assurance compliance (#8652)
* Add geo prop * Add prelim fetch * Add geo debug * Pass in assurance state to notifications registration * Comments * Bump git index * Add some component utils, no design, gate chat * Disable mod prefs buttons, does not yet edit mod prefs * Add initial prompt component * Refine logic for showing prompt * Add send email dialog * Hook up dialog to fake mutation * Fix geo debug bug * Move provider inside query provider * Slightly better screen gater * Ok decent fallback with isExempt * Reorg * Wrap prompt in new logic * Override mod prefs * Use real endpoints, optimistic state * Add persistent card, add time-ago, warning to dialog * Add comment * No undefined query values * Fix case in import * Wait for AA to load before registering push * Override prefs in all locations * Small refactor of notifications registration * Register push after aa state * Add retries * Update blocked screens UI * Strengthen email validation * Add intent dialog * Do service auth for init * Rug refreshJwt * Update copy * Some mobile styles, add dev mode option * Fix links on native * Clean up intent dialog on native * Don't mutate existing session, only copy * Handle email validation error from server * Clarity is better * Moar clear * Fixes * Tweaks * Add country code * Gate it * Refresh state after redirect * Re-check on window focus * Remove todo * Enable in dev * Check for did match on redirect * Add blocked state * Add appeal dialog * Copy tweaks * Inset in blue well * Nux the prompt * Copy updates * Refetch just in case * Uppercase country code * Align copy, add notice to chat screens * Tweak copy * Add test code * Add debug code * Refactor AccountCard * Big refactor * Delay post-feed queries instead * Debug code * Clean up state * Reorg * Clean up copy * Comments * Reorg * UPdate URL * Cleanup * Remove todo * Update debug code * revert unneeded changes * UPdate nux name * Revert unneeded change * Updaet storage schema * Checkpoint: cleanup * Checkpoint: almost there * isLoaded -> isReady * Rename useAgeAssurance * isUnderage -> isDeclaredUnderage * Decompose, add docblocks * Refactor * UPdate debug * Apply suggestion from @surfdude29 Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * Apply suggestion from @surfdude29 Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * Apply suggestion from @surfdude29 Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * Apply suggestion from @surfdude29 Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * Apply suggestion from @surfdude29 Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * Apply suggestion from @surfdude29 Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * Drop including Bluesky * Apply suggestion from @surfdude29 Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * Apply suggestion from @surfdude29 Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * Remove todo * Gate debug * Revert unneeded change * Fail closed * Comments * Comment * Comment * fix prettier * rm viewheader * bump sdk * prevent overlap in admonition * add age assurance intent route * Just meow Co-authored-by: Samuel Newman <mozzius@protonmail.com> * Nix callback * Fix spelling of dismissible lol * Don't compare translated string * Better KWS link labels * Hide DMs send options in menu * Add button * Fix order * Use only supported languages * Rm button * best-effort language mapping * improve typing --------- Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> Co-authored-by: Samuel Newman <mozzius@protonmail.com>
Diffstat (limited to 'src')
46 files changed, 2113 insertions, 177 deletions
diff --git a/src/App.native.tsx b/src/App.native.tsx index 2278b73de..87429d845 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -26,6 +26,7 @@ import I18nProvider from '#/locale/i18nProvider' import {logger} from '#/logger' import {isAndroid, isIOS} from '#/platform/detection' import {Provider as A11yProvider} from '#/state/a11y' +import {Provider as AgeAssuranceProvider} from '#/state/ageAssurance' import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes' import {Provider as DialogStateProvider} from '#/state/dialogs' import {listenSessionDropped} from '#/state/events' @@ -95,7 +96,6 @@ function InnerApp() { const {resumeSession} = useSessionApi() const theme = useColorModeTheme() const {_} = useLingui() - const hasCheckedReferrer = useStarterPackEntry() // init @@ -137,47 +137,49 @@ function InnerApp() { // Resets the entire tree below when it changes: key={currentAccount?.did}> <QueryProvider currentDid={currentAccount?.did}> - <ComposerProvider> - <StatsigProvider> - <MessagesProvider> - {/* LabelDefsProvider MUST come before ModerationOptsProvider */} - <LabelDefsProvider> - <ModerationOptsProvider> - <LoggedOutViewProvider> - <SelectedFeedProvider> - <HiddenRepliesProvider> - <HomeBadgeProvider> - <UnreadNotifsProvider> - <BackgroundNotificationPreferencesProvider> - <MutedThreadsProvider> - <ProgressGuideProvider> - <ServiceAccountManager> - <HideBottomBarBorderProvider> - <GestureHandlerRootView - style={s.h100pct}> - <GlobalGestureEventsProvider> - <IntentDialogProvider> - <TestCtrls /> - <Shell /> - <NuxDialogs /> - </IntentDialogProvider> - </GlobalGestureEventsProvider> - </GestureHandlerRootView> - </HideBottomBarBorderProvider> - </ServiceAccountManager> - </ProgressGuideProvider> - </MutedThreadsProvider> - </BackgroundNotificationPreferencesProvider> - </UnreadNotifsProvider> - </HomeBadgeProvider> - </HiddenRepliesProvider> - </SelectedFeedProvider> - </LoggedOutViewProvider> - </ModerationOptsProvider> - </LabelDefsProvider> - </MessagesProvider> - </StatsigProvider> - </ComposerProvider> + <StatsigProvider> + <AgeAssuranceProvider> + <ComposerProvider> + <MessagesProvider> + {/* LabelDefsProvider MUST come before ModerationOptsProvider */} + <LabelDefsProvider> + <ModerationOptsProvider> + <LoggedOutViewProvider> + <SelectedFeedProvider> + <HiddenRepliesProvider> + <HomeBadgeProvider> + <UnreadNotifsProvider> + <BackgroundNotificationPreferencesProvider> + <MutedThreadsProvider> + <ProgressGuideProvider> + <ServiceAccountManager> + <HideBottomBarBorderProvider> + <GestureHandlerRootView + style={s.h100pct}> + <GlobalGestureEventsProvider> + <IntentDialogProvider> + <TestCtrls /> + <Shell /> + <NuxDialogs /> + </IntentDialogProvider> + </GlobalGestureEventsProvider> + </GestureHandlerRootView> + </HideBottomBarBorderProvider> + </ServiceAccountManager> + </ProgressGuideProvider> + </MutedThreadsProvider> + </BackgroundNotificationPreferencesProvider> + </UnreadNotifsProvider> + </HomeBadgeProvider> + </HiddenRepliesProvider> + </SelectedFeedProvider> + </LoggedOutViewProvider> + </ModerationOptsProvider> + </LabelDefsProvider> + </MessagesProvider> + </ComposerProvider> + </AgeAssuranceProvider> + </StatsigProvider> </QueryProvider> </React.Fragment> </VideoVolumeProvider> diff --git a/src/App.web.tsx b/src/App.web.tsx index b706774fd..04de8529f 100644 --- a/src/App.web.tsx +++ b/src/App.web.tsx @@ -15,6 +15,7 @@ import {ThemeProvider} from '#/lib/ThemeContext' import I18nProvider from '#/locale/i18nProvider' import {logger} from '#/logger' import {Provider as A11yProvider} from '#/state/a11y' +import {Provider as AgeAssuranceProvider} from '#/state/ageAssurance' import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes' import {Provider as DialogStateProvider} from '#/state/dialogs' import {listenSessionDropped} from '#/state/events' @@ -116,43 +117,45 @@ function InnerApp() { // Resets the entire tree below when it changes: key={currentAccount?.did}> <QueryProvider currentDid={currentAccount?.did}> - <ComposerProvider> - <StatsigProvider> - <MessagesProvider> - {/* LabelDefsProvider MUST come before ModerationOptsProvider */} - <LabelDefsProvider> - <ModerationOptsProvider> - <LoggedOutViewProvider> - <SelectedFeedProvider> - <HiddenRepliesProvider> - <HomeBadgeProvider> - <UnreadNotifsProvider> - <BackgroundNotificationPreferencesProvider> - <MutedThreadsProvider> - <SafeAreaProvider> - <ProgressGuideProvider> - <ServiceConfigProvider> - <HideBottomBarBorderProvider> - <IntentDialogProvider> - <Shell /> - <NuxDialogs /> - </IntentDialogProvider> - </HideBottomBarBorderProvider> - </ServiceConfigProvider> - </ProgressGuideProvider> - </SafeAreaProvider> - </MutedThreadsProvider> - </BackgroundNotificationPreferencesProvider> - </UnreadNotifsProvider> - </HomeBadgeProvider> - </HiddenRepliesProvider> - </SelectedFeedProvider> - </LoggedOutViewProvider> - </ModerationOptsProvider> - </LabelDefsProvider> - </MessagesProvider> - </StatsigProvider> - </ComposerProvider> + <StatsigProvider> + <AgeAssuranceProvider> + <ComposerProvider> + <MessagesProvider> + {/* LabelDefsProvider MUST come before ModerationOptsProvider */} + <LabelDefsProvider> + <ModerationOptsProvider> + <LoggedOutViewProvider> + <SelectedFeedProvider> + <HiddenRepliesProvider> + <HomeBadgeProvider> + <UnreadNotifsProvider> + <BackgroundNotificationPreferencesProvider> + <MutedThreadsProvider> + <SafeAreaProvider> + <ProgressGuideProvider> + <ServiceConfigProvider> + <HideBottomBarBorderProvider> + <IntentDialogProvider> + <Shell /> + <NuxDialogs /> + </IntentDialogProvider> + </HideBottomBarBorderProvider> + </ServiceConfigProvider> + </ProgressGuideProvider> + </SafeAreaProvider> + </MutedThreadsProvider> + </BackgroundNotificationPreferencesProvider> + </UnreadNotifsProvider> + </HomeBadgeProvider> + </HiddenRepliesProvider> + </SelectedFeedProvider> + </LoggedOutViewProvider> + </ModerationOptsProvider> + </LabelDefsProvider> + </MessagesProvider> + </ComposerProvider> + </AgeAssuranceProvider> + </StatsigProvider> </QueryProvider> <ToastContainer /> </React.Fragment> diff --git a/src/components/LanguageSelect.tsx b/src/components/LanguageSelect.tsx new file mode 100644 index 000000000..2ad3949ae --- /dev/null +++ b/src/components/LanguageSelect.tsx @@ -0,0 +1,50 @@ +import React from 'react' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {sanitizeAppLanguageSetting} from '#/locale/helpers' +import {APP_LANGUAGES} from '#/locale/languages' +import * as Select from '#/components/Select' + +export function LanguageSelect({ + value, + onChange, + items = APP_LANGUAGES.map(l => ({ + label: l.name, + value: l.code2, + })), +}: { + value?: string + onChange: (value: string) => void + items?: {label: string; value: string}[] +}) { + const {_} = useLingui() + + const handleOnChange = React.useCallback( + (value: string) => { + if (!value) return + onChange(sanitizeAppLanguageSetting(value)) + }, + [onChange], + ) + + return ( + <Select.Root + value={value ? sanitizeAppLanguageSetting(value) : undefined} + onValueChange={handleOnChange}> + <Select.Trigger label={_(msg`Select language`)}> + <Select.ValueText placeholder={_(msg`Select language`)} /> + <Select.Icon /> + </Select.Trigger> + <Select.Content + renderItem={({label, value}) => ( + <Select.Item value={value} label={label}> + <Select.ItemIndicator /> + <Select.ItemText>{label}</Select.ItemText> + </Select.Item> + )} + items={items} + /> + </Select.Root> + ) +} diff --git a/src/components/PostControls/ShareMenu/ShareMenuItems.tsx b/src/components/PostControls/ShareMenu/ShareMenuItems.tsx index 1c04f3174..03b113708 100644 --- a/src/components/PostControls/ShareMenu/ShareMenuItems.tsx +++ b/src/components/PostControls/ShareMenu/ShareMenuItems.tsx @@ -11,6 +11,7 @@ import {shareText, shareUrl} from '#/lib/sharing' import {toShareUrl} from '#/lib/strings/url-helpers' import {logger} from '#/logger' import {isIOS} from '#/platform/detection' +import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance' import {useProfileShadow} from '#/state/cache/profile-shadow' import {useSession} from '#/state/session' import * as Toast from '#/view/com/util/Toast' @@ -36,6 +37,7 @@ let ShareMenuItems = ({ const navigation = useNavigation<NavigationProp>() const sendViaChatControl = useDialogControl() const [devModeEnabled] = useDevMode() + const {isAgeRestricted} = useAgeAssurance() const postUri = post.uri const postAuthor = useProfileShadow(post.author) @@ -89,7 +91,7 @@ let ShareMenuItems = ({ return ( <> <Menu.Outer> - {hasSession && ( + {hasSession && !isAgeRestricted && ( <Menu.Group> <Menu.ContainerItem> <RecentChats postUri={postUri} /> diff --git a/src/components/PostControls/ShareMenu/ShareMenuItems.web.tsx b/src/components/PostControls/ShareMenu/ShareMenuItems.web.tsx index 8d52a2fdf..d074cdcf0 100644 --- a/src/components/PostControls/ShareMenu/ShareMenuItems.web.tsx +++ b/src/components/PostControls/ShareMenu/ShareMenuItems.web.tsx @@ -11,6 +11,7 @@ import {shareText, shareUrl} from '#/lib/sharing' import {toShareUrl} from '#/lib/strings/url-helpers' import {logger} from '#/logger' import {isWeb} from '#/platform/detection' +import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance' import {useProfileShadow} from '#/state/cache/profile-shadow' import {useSession} from '#/state/session' import {useBreakpoints} from '#/alf' @@ -38,6 +39,7 @@ let ShareMenuItems = ({ const embedPostControl = useDialogControl() const sendViaChatControl = useDialogControl() const [devModeEnabled] = useDevMode() + const {isAgeRestricted} = useAgeAssurance() const postUri = post.uri const postCid = post.cid @@ -96,7 +98,7 @@ let ShareMenuItems = ({ <Menu.Outer> {!hideInPWI && copyLinkItem} - {hasSession && ( + {hasSession && !isAgeRestricted && ( <Menu.Item testID="postDropdownSendViaDMBtn" label={_(msg`Send via direct message`)} diff --git a/src/components/ageAssurance/AgeAssuranceAccountCard.tsx b/src/components/ageAssurance/AgeAssuranceAccountCard.tsx new file mode 100644 index 000000000..530e43d44 --- /dev/null +++ b/src/components/ageAssurance/AgeAssuranceAccountCard.tsx @@ -0,0 +1,148 @@ +import {View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {dateDiff, useGetTimeAgo} from '#/lib/hooks/useTimeAgo' +import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance' +import {atoms as a, useBreakpoints, useTheme, type ViewStyleProp} from '#/alf' +import {Admonition} from '#/components/Admonition' +import {AgeAssuranceAppealDialog} from '#/components/ageAssurance/AgeAssuranceAppealDialog' +import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge' +import { + AgeAssuranceInitDialog, + useDialogControl, +} from '#/components/ageAssurance/AgeAssuranceInitDialog' +import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy' +import {Button, ButtonText} from '#/components/Button' +import * as Dialog from '#/components/Dialog' +import {Divider} from '#/components/Divider' +import {createStaticClick, InlineLinkText} from '#/components/Link' +import {Text} from '#/components/Typography' + +export function AgeAssuranceAccountCard({style}: ViewStyleProp & {}) { + const {isReady, isAgeRestricted, isDeclaredUnderage} = useAgeAssurance() + + if (!isReady) return null + if (isDeclaredUnderage) return null + if (!isAgeRestricted) return null + + return <Inner style={style} /> +} + +function Inner({style}: ViewStyleProp & {}) { + const t = useTheme() + const {_, i18n} = useLingui() + const control = useDialogControl() + const appealControl = Dialog.useDialogControl() + const getTimeAgo = useGetTimeAgo() + const {gtPhone} = useBreakpoints() + + const copy = useAgeAssuranceCopy() + const {status, lastInitiatedAt} = useAgeAssurance() + const isBlocked = status === 'blocked' + const hasInitiated = !!lastInitiatedAt + const timeAgo = lastInitiatedAt + ? getTimeAgo(lastInitiatedAt, new Date()) + : null + const diff = lastInitiatedAt + ? dateDiff(lastInitiatedAt, new Date(), 'down') + : null + + return ( + <> + <AgeAssuranceInitDialog control={control} /> + <AgeAssuranceAppealDialog control={appealControl} /> + + <View style={style}> + <View + style={[a.p_lg, a.rounded_md, a.border, t.atoms.border_contrast_low]}> + <View + style={[ + a.flex_row, + a.justify_between, + a.align_center, + a.gap_lg, + a.pb_md, + a.z_10, + ]}> + <View style={[a.align_start]}> + <AgeAssuranceBadge /> + </View> + </View> + + <View style={[a.pb_md]}> + <Text style={[a.text_sm, a.leading_snug]}>{copy.notice}</Text> + </View> + + {isBlocked ? ( + <Admonition type="warning"> + <Trans> + You are currently unable to access Bluesky's Age Assurance flow. + Please{' '} + <InlineLinkText + label={_(msg`Contact our moderation team`)} + {...createStaticClick(() => { + appealControl.open() + })}> + contact our moderation team + </InlineLinkText>{' '} + if you believe this is an error. + </Trans> + </Admonition> + ) : ( + <> + <Divider /> + <View + style={[ + a.pt_md, + gtPhone + ? [ + a.flex_row_reverse, + a.gap_xl, + a.justify_between, + a.align_center, + ] + : [a.gap_md], + ]}> + <Button + label={_(msg`Verify now`)} + size="small" + variant="solid" + color={hasInitiated ? 'secondary' : 'primary'} + onPress={() => control.open()}> + <ButtonText> + {hasInitiated ? ( + <Trans>Verify again</Trans> + ) : ( + <Trans>Verify now</Trans> + )} + </ButtonText> + </Button> + + {lastInitiatedAt && timeAgo && diff ? ( + <Text + style={[a.text_sm, a.italic, t.atoms.text_contrast_medium]} + title={i18n.date(lastInitiatedAt, { + dateStyle: 'medium', + timeStyle: 'medium', + })}> + {diff.value === 0 ? ( + <Trans>Last initiated just now</Trans> + ) : ( + <Trans>Last initiated {timeAgo} ago</Trans> + )} + </Text> + ) : ( + <Text + style={[a.text_sm, a.italic, t.atoms.text_contrast_medium]}> + <Trans>Age assurance only takes a few minutes</Trans> + </Text> + )} + </View> + </> + )} + </View> + </View> + </> + ) +} diff --git a/src/components/ageAssurance/AgeAssuranceAdmonition.tsx b/src/components/ageAssurance/AgeAssuranceAdmonition.tsx new file mode 100644 index 000000000..d140b7873 --- /dev/null +++ b/src/components/ageAssurance/AgeAssuranceAdmonition.tsx @@ -0,0 +1,100 @@ +import {View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance' +import {atoms as a, select, useTheme, type ViewStyleProp} from '#/alf' +import {useDialogControl} from '#/components/ageAssurance/AgeAssuranceInitDialog' +import type * as Dialog from '#/components/Dialog' +import {ShieldCheck_Stroke2_Corner0_Rounded as Shield} from '#/components/icons/Shield' +import {InlineLinkText} from '#/components/Link' +import {Text} from '#/components/Typography' + +export function AgeAssuranceAdmonition({ + children, + style, +}: ViewStyleProp & {children: React.ReactNode}) { + const control = useDialogControl() + const {isReady, isDeclaredUnderage, isAgeRestricted} = useAgeAssurance() + + if (!isReady) return null + if (isDeclaredUnderage) return null + if (!isAgeRestricted) return null + + return ( + <Inner style={style} control={control}> + {children} + </Inner> + ) +} + +function Inner({ + children, + style, +}: ViewStyleProp & { + children: React.ReactNode + control: Dialog.DialogControlProps +}) { + const t = useTheme() + const {_} = useLingui() + + return ( + <> + <View style={style}> + <View + style={[ + a.p_md, + a.rounded_md, + a.border, + a.flex_row, + a.align_start, + a.gap_sm, + { + backgroundColor: select(t.name, { + light: t.palette.primary_25, + dark: t.palette.primary_25, + dim: t.palette.primary_25, + }), + borderColor: select(t.name, { + light: t.palette.primary_100, + dark: t.palette.primary_100, + dim: t.palette.primary_100, + }), + }, + ]}> + <View + style={[ + a.align_center, + a.justify_center, + a.rounded_full, + { + width: 32, + height: 32, + backgroundColor: select(t.name, { + light: t.palette.primary_100, + dark: t.palette.primary_100, + dim: t.palette.primary_100, + }), + }, + ]}> + <Shield size="md" /> + </View> + <View style={[a.flex_1, a.gap_xs, a.pr_2xl]}> + <Text style={[a.text_sm, a.leading_snug]}>{children}</Text> + <Text style={[a.text_sm, a.leading_snug, a.font_bold]}> + <Trans> + Learn more in your{' '} + <InlineLinkText + label={_(msg`Go to account settings`)} + to={'/settings/account'} + style={[a.text_sm, a.leading_snug, a.font_bold]}> + account settings. + </InlineLinkText> + </Trans> + </Text> + </View> + </View> + </View> + </> + ) +} diff --git a/src/components/ageAssurance/AgeAssuranceAppealDialog.tsx b/src/components/ageAssurance/AgeAssuranceAppealDialog.tsx new file mode 100644 index 000000000..166f6c26d --- /dev/null +++ b/src/components/ageAssurance/AgeAssuranceAppealDialog.tsx @@ -0,0 +1,140 @@ +import React from 'react' +import {View} from 'react-native' +import {BSKY_LABELER_DID, ComAtprotoModerationDefs} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useMutation} from '@tanstack/react-query' + +import {logger} from '#/logger' +import {useAgent, useSession} from '#/state/session' +import * as Toast from '#/view/com/util/Toast' +import {atoms as a, useBreakpoints, web} from '#/alf' +import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import * as Dialog from '#/components/Dialog' +import {Loader} from '#/components/Loader' +import {Text} from '#/components/Typography' + +export function AgeAssuranceAppealDialog({ + control, +}: { + control: Dialog.DialogControlProps +}) { + const {_} = useLingui() + return ( + <Dialog.Outer control={control}> + <Dialog.Handle /> + <Dialog.ScrollableInner + label={_(msg`Contact our moderation team`)} + style={[web({maxWidth: 400})]}> + <Inner control={control} /> + <Dialog.Close /> + </Dialog.ScrollableInner> + </Dialog.Outer> + ) +} + +function Inner({control}: {control: Dialog.DialogControlProps}) { + const {_} = useLingui() + const {currentAccount} = useSession() + const {gtPhone} = useBreakpoints() + const agent = useAgent() + + const [details, setDetails] = React.useState('') + const isInvalid = details.length > 1000 + + const {mutate, isPending} = useMutation({ + mutationFn: async () => { + await agent.createModerationReport( + { + reasonType: ComAtprotoModerationDefs.REASONAPPEAL, + subject: { + $type: 'com.atproto.admin.defs#repoRef', + did: currentAccount?.did, + }, + reason: `AGE_ASSURANCE_INQUIRY: ` + details, + }, + { + encoding: 'application/json', + headers: { + 'atproto-proxy': `${BSKY_LABELER_DID}#atproto_labeler`, + }, + }, + ) + }, + onError: err => { + logger.error('AgeAssuranceAppealDialog failed', {safeMessage: err}) + Toast.show( + _(msg`Age assurance inquiry failed to send, please try again.`), + 'xmark', + ) + }, + onSuccess: () => { + control.close() + Toast.show( + _( + msg({ + message: 'Age assurance inquiry was submitted', + context: 'toast', + }), + ), + ) + }, + }) + + return ( + <View> + <View style={[a.align_start]}> + <AgeAssuranceBadge /> + </View> + + <Text style={[a.text_2xl, a.font_heavy, a.pt_md, a.leading_tight]}> + <Trans>Contact us</Trans> + </Text> + + <Text style={[a.text_sm, a.pt_sm, a.leading_snug]}> + <Trans> + Please provide any additional details you feel moderators may need in + order to properly assess your Age Assurance status. + </Trans> + </Text> + + <View style={[a.pt_md]}> + <Dialog.Input + multiline + isInvalid={isInvalid} + value={details} + onChangeText={details => { + setDetails(details) + }} + label={_(msg`Additional details (limit 1000 characters)`)} + numberOfLines={4} + onSubmitEditing={() => mutate()} + /> + <View style={[a.pt_md, a.gap_sm, gtPhone && [a.flex_row_reverse]]}> + <Button + label={_(msg`Submit`)} + size="small" + variant="solid" + color="primary" + onPress={() => mutate()}> + <ButtonText> + <Trans>Submit</Trans> + </ButtonText> + {isPending && <ButtonIcon icon={Loader} position="right" />} + </Button> + <Button + label={_(msg`Cancel`)} + size="small" + variant="solid" + color="secondary" + onPress={() => control.close()}> + <ButtonText> + <Trans>Cancel</Trans> + </ButtonText> + </Button> + </View> + </View> + </View> + ) +} diff --git a/src/components/ageAssurance/AgeAssuranceBadge.tsx b/src/components/ageAssurance/AgeAssuranceBadge.tsx new file mode 100644 index 000000000..030e30529 --- /dev/null +++ b/src/components/ageAssurance/AgeAssuranceBadge.tsx @@ -0,0 +1,46 @@ +import {View} from 'react-native' +import {Trans} from '@lingui/macro' + +import {atoms as a, select, useTheme} from '#/alf' +import {ShieldCheck_Stroke2_Corner0_Rounded as Shield} from '#/components/icons/Shield' +import {Text} from '#/components/Typography' + +export function AgeAssuranceBadge() { + const t = useTheme() + + return ( + <View + style={[ + a.flex_row, + a.align_center, + a.gap_xs, + a.px_sm, + a.py_xs, + a.pr_sm, + a.rounded_full, + { + 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> + ) +} diff --git a/src/components/ageAssurance/AgeAssuranceDismissibleHeaderButton.tsx b/src/components/ageAssurance/AgeAssuranceDismissibleHeaderButton.tsx new file mode 100644 index 000000000..b6505fb0e --- /dev/null +++ b/src/components/ageAssurance/AgeAssuranceDismissibleHeaderButton.tsx @@ -0,0 +1,95 @@ +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> + ) +} diff --git a/src/components/ageAssurance/AgeAssuranceDismissibleNotice.tsx b/src/components/ageAssurance/AgeAssuranceDismissibleNotice.tsx new file mode 100644 index 000000000..30e2fbec4 --- /dev/null +++ b/src/components/ageAssurance/AgeAssuranceDismissibleNotice.tsx @@ -0,0 +1,59 @@ +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, type ViewStyleProp} from '#/alf' +import {AgeAssuranceAdmonition} from '#/components/ageAssurance/AgeAssuranceAdmonition' +import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy' +import {Button, ButtonIcon} from '#/components/Button' +import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' + +export function AgeAssuranceDismissibleNotice({style}: ViewStyleProp & {}) { + const {_} = useLingui() + const {isReady, isDeclaredUnderage, isAgeRestricted, lastInitiatedAt} = + useAgeAssurance() + const {nux} = useNux(Nux.AgeAssuranceDismissibleNotice) + const copy = useAgeAssuranceCopy() + const {mutate: save, variables} = useSaveNux() + const hidden = !!variables + + if (!isReady) return null + if (isDeclaredUnderage) return null + if (!isAgeRestricted) return null + if (lastInitiatedAt) return null + if (hidden) return null + if (nux && nux.completed) return null + + return ( + <View style={style}> + <View> + <AgeAssuranceAdmonition>{copy.notice}</AgeAssuranceAdmonition> + + <Button + label={_(msg`Don't show again`)} + size="tiny" + variant="solid" + color="secondary_inverted" + shape="round" + onPress={() => + save({ + id: Nux.AgeAssuranceDismissibleNotice, + completed: true, + data: undefined, + }) + } + style={[ + a.absolute, + { + top: 12, + right: 12, + }, + ]}> + <ButtonIcon icon={X} /> + </Button> + </View> + </View> + ) +} diff --git a/src/components/ageAssurance/AgeAssuranceInitDialog.tsx b/src/components/ageAssurance/AgeAssuranceInitDialog.tsx new file mode 100644 index 000000000..ad13cc1c2 --- /dev/null +++ b/src/components/ageAssurance/AgeAssuranceInitDialog.tsx @@ -0,0 +1,351 @@ +import {useState} from 'react' +import {View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {validate as validateEmail} from 'email-validator' + +import {useCleanError} from '#/lib/hooks/useCleanError' +import {useGetTimeAgo} from '#/lib/hooks/useTimeAgo' +import {useTLDs} from '#/lib/hooks/useTLDs' +import {isEmailMaybeInvalid} from '#/lib/strings/email' +import {type AppLanguage} from '#/locale/languages' +import {useAgeAssuranceContext} from '#/state/ageAssurance' +import {useInitAgeAssurance} from '#/state/ageAssurance/useInitAgeAssurance' +import {useLanguagePrefs} from '#/state/preferences' +import {useSession} from '#/state/session' +import {atoms as a, useTheme, web} from '#/alf' +import {Admonition} from '#/components/Admonition' +import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge' +import {urls} from '#/components/ageAssurance/const' +import {KWS_SUPPORTED_LANGS} from '#/components/ageAssurance/const' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import * as Dialog from '#/components/Dialog' +import {Divider} from '#/components/Divider' +import * as TextField from '#/components/forms/TextField' +import {ShieldCheck_Stroke2_Corner0_Rounded as Shield} from '#/components/icons/Shield' +import {LanguageSelect} from '#/components/LanguageSelect' +import {InlineLinkText} from '#/components/Link' +import {Loader} from '#/components/Loader' +import {Text} from '#/components/Typography' + +export {useDialogControl} from '#/components/Dialog/context' + +export function AgeAssuranceInitDialog({ + control, +}: { + control: Dialog.DialogControlProps +}) { + const {_} = useLingui() + return ( + <Dialog.Outer control={control}> + <Dialog.Handle /> + + <Dialog.ScrollableInner + label={_( + msg`Begin the age assurance process by completing the fields below.`, + )} + style={[ + web({ + maxWidth: 400, + }), + ]}> + <Inner /> + <Dialog.Close /> + </Dialog.ScrollableInner> + </Dialog.Outer> + ) +} + +function Inner() { + const t = useTheme() + const {_} = useLingui() + const {currentAccount} = useSession() + const langPrefs = useLanguagePrefs() + const cleanError = useCleanError() + const {close} = Dialog.useDialogContext() + const {lastInitiatedAt} = useAgeAssuranceContext() + const getTimeAgo = useGetTimeAgo() + const tlds = useTLDs() + + const wasRecentlyInitiated = + lastInitiatedAt && + new Date(lastInitiatedAt).getTime() > Date.now() - 5 * 60 * 1000 // 5 minutes + + const [success, setSuccess] = useState(false) + const [email, setEmail] = useState(currentAccount?.email || '') + const [emailError, setEmailError] = useState<string>('') + const [languageError, setLanguageError] = useState(false) + const [disabled, setDisabled] = useState(false) + const [language, setLanguage] = useState<string | undefined>( + convertToKWSSupportedLanguage(langPrefs.appLanguage), + ) + const [error, setError] = useState<string>('') + + const {mutateAsync: init, isPending} = useInitAgeAssurance() + + const runEmailValidation = () => { + if (validateEmail(email)) { + setEmailError('') + setDisabled(false) + + if (tlds && isEmailMaybeInvalid(email, tlds)) { + setEmailError( + _( + msg`Please double-check that you have entered your email address correctly.`, + ), + ) + return {status: 'maybe'} + } + + return {status: 'valid'} + } + + setEmailError(_(msg`Please enter a valid email address.`)) + setDisabled(true) + + return {status: 'invalid'} + } + + const onSubmit = async () => { + setLanguageError(false) + + try { + const {status} = runEmailValidation() + + if (status === 'invalid') return + if (!language) { + setLanguageError(true) + return + } + + await init({ + email, + language, + }) + + setSuccess(true) + } catch (e) { + const {clean, raw} = cleanError(e) + + if (clean) { + setError(clean || _(msg`Something went wrong, please try again`)) + } else { + let message = _(msg`Something went wrong, please try again`) + + if (raw) { + if (raw.startsWith('This email address is not supported')) { + message = _( + msg`Please enter a valid, non-temporary email address. You may need to access this email in the future.`, + ) + } + } + + setError(message) + } + } + } + + return ( + <View> + <View style={[a.align_start]}> + <AgeAssuranceBadge /> + + <Text style={[a.text_xl, a.font_heavy, a.pt_xl, a.pb_md]}> + {success ? <Trans>Success!</Trans> : <Trans>Verify your age</Trans>} + </Text> + + <View style={[a.pb_xl, a.gap_sm]}> + {success ? ( + <Text style={[a.text_sm, a.leading_snug]}> + <Trans> + Please check your email inbox for further instructions. It may + take a minute or two to arrive. + </Trans> + </Text> + ) : ( + <> + <Text style={[a.text_sm, a.leading_snug]}> + <Trans> + We use{' '} + <InlineLinkText + overridePresentation + disableMismatchWarning + label={_(msg`KWS website`)} + to={urls.kwsHome} + style={[a.text_sm, a.leading_snug]}> + KWS + </InlineLinkText>{' '} + to verify that you’re an adult. When you click "Begin" below, + KWS will email you instructions for verifying your age. When + you’re done, you'll be brought back to continue using Bluesky. + </Trans> + </Text> + <Text style={[a.text_sm, a.leading_snug]}> + <Trans>This should only take a few minutes.</Trans> + </Text> + </> + )} + </View> + + {success ? ( + <View style={[a.w_full]}> + <Button + label={_(msg`Close dialog`)} + size="large" + variant="solid" + color="secondary" + onPress={() => close()}> + <ButtonText> + <Trans>Close dialog</Trans> + </ButtonText> + </Button> + </View> + ) : ( + <> + <Divider /> + + <View style={[a.w_full, a.pt_xl, a.gap_lg, a.pb_lg]}> + {wasRecentlyInitiated && ( + <Admonition type="warning"> + <Trans> + You initiated this flow already,{' '} + {getTimeAgo(lastInitiatedAt, new Date(), {format: 'long'})}{' '} + ago. It may take up to 5 minutes for emails to reach your + inbox. Please consider waiting a few minutes before trying + again. + </Trans> + </Admonition> + )} + + <View> + <TextField.LabelText> + <Trans>Your email</Trans> + </TextField.LabelText> + <TextField.Root isInvalid={!!emailError}> + <TextField.Input + label={_(msg`Your email`)} + placeholder={_(msg`Your email`)} + value={email} + onChangeText={setEmail} + onFocus={() => setEmailError('')} + onBlur={() => { + runEmailValidation() + }} + returnKeyType="done" + autoCapitalize="none" + autoComplete="off" + autoCorrect={false} + onSubmitEditing={onSubmit} + /> + </TextField.Root> + + {emailError ? ( + <Admonition type="error" style={[a.mt_sm]}> + {emailError} + </Admonition> + ) : ( + <Admonition type="tip" style={[a.mt_sm]}> + <Trans> + Use your account email address, or another real email + address you control, in case KWS or Bluesky needs to + contact you. + </Trans> + </Admonition> + )} + </View> + + <View> + <TextField.LabelText> + <Trans>Your preferred language</Trans> + </TextField.LabelText> + <LanguageSelect + value={language} + onChange={value => { + setLanguage(value) + setLanguageError(false) + }} + items={KWS_SUPPORTED_LANGS} + /> + + {languageError && ( + <Admonition type="error" style={[a.mt_sm]}> + <Trans>Please select a language</Trans> + </Admonition> + )} + </View> + + {error && <Admonition type="error">{error}</Admonition>} + + <Button + disabled={disabled} + label={_(msg`Begin age assurance process`)} + size="large" + variant="solid" + color="primary" + onPress={onSubmit}> + <ButtonText> + <Trans>Begin</Trans> + </ButtonText> + <ButtonIcon + icon={isPending ? Loader : Shield} + position="right" + /> + </Button> + </View> + + <Text + style={[a.text_xs, a.leading_snug, t.atoms.text_contrast_medium]}> + <Trans> + By continuing, you agree to the{' '} + <InlineLinkText + overridePresentation + disableMismatchWarning + label={_(msg`KWS Terms of Use`)} + to={urls.kwsTermsOfUse} + style={[a.text_xs, a.leading_snug]}> + KWS Terms of Use + </InlineLinkText>{' '} + and acknowledge that KWS will store your verified status with + your hashed email address in accordance with the{' '} + <InlineLinkText + overridePresentation + disableMismatchWarning + label={_(msg`KWS Privacy Policy`)} + to={urls.kwsPrivacyPolicy} + style={[a.text_xs, a.leading_snug]}> + KWS Privacy Policy + </InlineLinkText> + . This means you won’t need to verify again the next time you + use this email for other apps, games, and services powered by + KWS technology. + </Trans> + </Text> + </> + )} + </View> + </View> + ) +} + +// best-effort mapping of our languages to KWS supported languages +function convertToKWSSupportedLanguage( + appLanguage: string, +): string | undefined { + // `${Enum}` is how you get a type of string union of the enum values (???) -sfn + switch (appLanguage as `${AppLanguage}`) { + // only en is supported + case 'en-GB': + return 'en' + // pt-PT is pt (pt-BR is supported independently) + case 'pt-PT': + return 'pt' + // only chinese (simplified) is supported, map all chinese variants + case 'zh-Hans-CN': + case 'zh-Hant-HK': + case 'zh-Hant-TW': + return 'zh-Hans' + default: + // try and map directly - if undefined, they will have to pick from the dropdown + return KWS_SUPPORTED_LANGS.find(v => v.value === appLanguage)?.value + } +} diff --git a/src/components/ageAssurance/AgeAssuranceRedirectDialog.tsx b/src/components/ageAssurance/AgeAssuranceRedirectDialog.tsx new file mode 100644 index 000000000..41e706fee --- /dev/null +++ b/src/components/ageAssurance/AgeAssuranceRedirectDialog.tsx @@ -0,0 +1,196 @@ +import {useEffect, useRef, useState} from 'react' +import {View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {retry} from '#/lib/async/retry' +import {wait} from '#/lib/async/wait' +import {isNative} from '#/platform/detection' +import {useAgeAssuranceAPIContext} from '#/state/ageAssurance' +import {useAgent} from '#/state/session' +import {atoms as a, useTheme, web} from '#/alf' +import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge' +import {Button, ButtonText} from '#/components/Button' +import * as Dialog from '#/components/Dialog' +import {useGlobalDialogsControlContext} from '#/components/dialogs/Context' +import {CircleInfo_Stroke2_Corner0_Rounded as ErrorIcon} from '#/components/icons/CircleInfo' +import {Loader} from '#/components/Loader' +import {Text} from '#/components/Typography' + +export type AgeAssuranceRedirectDialogState = { + result: 'success' | 'unknown' + actorDid: string +} + +/** + * Validate and parse the query parameters returned from the age assurance + * redirect. If not valid, returns `undefined` and the dialog will not open. + */ +export function parseAgeAssuranceRedirectDialogState( + state: { + result?: string + actorDid?: string + } = {}, +): AgeAssuranceRedirectDialogState | undefined { + let result: AgeAssuranceRedirectDialogState['result'] = 'unknown' + const actorDid = state.actorDid + + switch (state.result) { + case 'success': + result = 'success' + break + case 'unknown': + default: + result = 'unknown' + break + } + + if (result && actorDid) { + return { + result, + actorDid, + } + } +} + +export function useAgeAssuranceRedirectDialogControl() { + return useGlobalDialogsControlContext().ageAssuranceRedirectDialogControl +} + +export function AgeAssuranceRedirectDialog() { + const {_} = useLingui() + const control = useAgeAssuranceRedirectDialogControl() + + // TODO for testing + // Dialog.useAutoOpen(control.control, 3e3) + + return ( + <Dialog.Outer control={control.control}> + <Dialog.Handle /> + + <Dialog.ScrollableInner + label={_(msg`Verifying your age assurance status`)} + style={[web({maxWidth: 400})]}> + <Inner optimisticState={control.value} /> + </Dialog.ScrollableInner> + </Dialog.Outer> + ) +} + +export function Inner({}: {optimisticState?: AgeAssuranceRedirectDialogState}) { + const t = useTheme() + const {_} = useLingui() + const agent = useAgent() + const polling = useRef(false) + const unmounted = useRef(false) + const control = useAgeAssuranceRedirectDialogControl() + const [error, setError] = useState(false) + const {refetch: refreshAgeAssuranceState} = useAgeAssuranceAPIContext() + + useEffect(() => { + if (polling.current) return + + polling.current = true + + wait( + 3e3, + retry( + 5, + () => true, + async () => { + if (!agent.session) return + if (unmounted.current) return + + const {data} = await agent.app.bsky.unspecced.getAgeAssuranceState() + + if (data.status !== 'assured') { + throw new Error( + `Polling for age assurance state did not receive assured status`, + ) + } + + return data + }, + 1e3, + ), + ) + .then(async data => { + if (!data) return + if (!agent.session) return + if (unmounted.current) return + + // success! update state + await refreshAgeAssuranceState() + + control.clear() + control.control.close() + }) + .catch(() => { + if (unmounted.current) return + setError(true) + // try a refetch anyway + refreshAgeAssuranceState() + }) + + return () => { + unmounted.current = true + } + }, [agent, control, refreshAgeAssuranceState]) + + return ( + <> + <View style={[a.align_start, a.w_full]}> + <AgeAssuranceBadge /> + + <View + style={[ + a.flex_row, + a.justify_between, + a.align_center, + a.gap_sm, + a.pt_lg, + a.pb_md, + ]}> + {error && <ErrorIcon size="md" fill={t.palette.negative_500} />} + + <Text style={[a.text_xl, a.font_heavy]}> + {error ? <Trans>Connection issue</Trans> : <Trans>Verifying</Trans>} + </Text> + + {!error && <Loader size="md" />} + </View> + + <Text style={[a.text_md, a.leading_snug]}> + {error ? ( + <Trans> + We were unable to receive the verification due to a connection + issue. It may arrive later. If it does, your account will update + automatically. + </Trans> + ) : ( + <Trans> + We're confirming your status with our servers. This dialog should + close in a few seconds. + </Trans> + )} + </Text> + + {error && isNative && ( + <View style={[a.w_full, a.pt_lg]}> + <Button + label={_(msg`Close`)} + size="large" + variant="solid" + color="secondary"> + <ButtonText> + <Trans>Close</Trans> + </ButtonText> + </Button> + </View> + )} + </View> + + {error && <Dialog.Close />} + </> + ) +} diff --git a/src/components/ageAssurance/AgeRestrictedScreen.tsx b/src/components/ageAssurance/AgeRestrictedScreen.tsx new file mode 100644 index 000000000..2a9882415 --- /dev/null +++ b/src/components/ageAssurance/AgeRestrictedScreen.tsx @@ -0,0 +1,93 @@ +import {View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance' +import {atoms as a} from '#/alf' +import {Admonition} from '#/components/Admonition' +import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge' +import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy' +import {ButtonIcon, ButtonText} from '#/components/Button' +import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron' +import * as Layout from '#/components/Layout' +import {Link} from '#/components/Link' +import {Text} from '#/components/Typography' + +export function AgeRestrictedScreen({ + children, + screenTitle, + infoText, +}: { + children: React.ReactNode + screenTitle?: string + infoText?: string +}) { + const {_} = useLingui() + const copy = useAgeAssuranceCopy() + const {isReady, isAgeRestricted} = useAgeAssurance() + + if (!isReady) { + return ( + <Layout.Screen> + <Layout.Header.Outer> + <Layout.Header.Content> + <Layout.Header.TitleText> </Layout.Header.TitleText> + </Layout.Header.Content> + <Layout.Header.Slot /> + </Layout.Header.Outer> + <Layout.Content /> + </Layout.Screen> + ) + } + if (!isAgeRestricted) return children + + return ( + <Layout.Screen> + <Layout.Header.Outer> + <Layout.Header.BackButton /> + <Layout.Header.Content> + <Layout.Header.TitleText> + {screenTitle ?? <Trans>Unavailable</Trans>} + </Layout.Header.TitleText> + </Layout.Header.Content> + <Layout.Header.Slot /> + </Layout.Header.Outer> + <Layout.Content> + <View style={[a.p_lg]}> + <View style={[a.align_start, a.pb_lg]}> + <AgeAssuranceBadge /> + </View> + + <View style={[a.gap_sm, a.pb_lg]}> + <Text style={[a.text_xl, a.leading_snug, a.font_heavy]}> + <Trans> + You must verify your age in order to access this screen. + </Trans> + </Text> + + <Text style={[a.text_md, a.leading_snug]}> + <Trans>{copy.notice}</Trans> + </Text> + </View> + + <View + style={[a.flex_row, a.justify_between, a.align_center, a.pb_xl]}> + <Link + label={_(msg`Go to account settings`)} + to="/settings/account" + size="small" + variant="solid" + color="primary"> + <ButtonText> + <Trans>Go to account settings</Trans> + </ButtonText> + <ButtonIcon icon={ChevronRight} position="right" /> + </Link> + </View> + + {infoText && <Admonition type="tip">{infoText}</Admonition>} + </View> + </Layout.Content> + </Layout.Screen> + ) +} diff --git a/src/components/ageAssurance/const.ts b/src/components/ageAssurance/const.ts new file mode 100644 index 000000000..35f96e841 --- /dev/null +++ b/src/components/ageAssurance/const.ts @@ -0,0 +1,26 @@ +export const urls = { + kwsHome: 'https://www.kidswebservices.com/en-US', + kwsTermsOfUse: 'https://www.kidswebservices.com/en-US/terms-of-use', + kwsPrivacyPolicy: 'https://www.kidswebservices.com/en-US/privacy-policy', +} + +export const KWS_SUPPORTED_LANGS = [ + {value: 'en', label: 'English'}, + {value: 'ar', label: 'العربية'}, + {value: 'zh-Hans', label: '简体中文'}, + {value: 'nl', label: 'Nederlands'}, + {value: 'tl', label: 'Filipino'}, + {value: 'fr', label: 'Français'}, + {value: 'de', label: 'Deutsch'}, + {value: 'id', label: 'Bahasa Indonesia'}, + {value: 'it', label: 'Italiano'}, + {value: 'ja', label: '日本語'}, + {value: 'ko', label: '한국어'}, + {value: 'pt', label: 'Português'}, + {value: 'pt-BR', label: 'Português (Brasil)'}, + {value: 'ru', label: 'Русский'}, + {value: 'es', label: 'Español'}, + {value: 'tr', label: 'Türkçe'}, + {value: 'th', label: 'ภาษาไทย'}, + {value: 'vi', label: 'Tiếng Việt'}, +] diff --git a/src/components/ageAssurance/useAgeAssuranceCopy.ts b/src/components/ageAssurance/useAgeAssuranceCopy.ts new file mode 100644 index 000000000..045806994 --- /dev/null +++ b/src/components/ageAssurance/useAgeAssuranceCopy.ts @@ -0,0 +1,18 @@ +import {useMemo} from 'react' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +export function useAgeAssuranceCopy() { + const {_} = useLingui() + + return useMemo(() => { + return { + 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.`, + ), + 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/components/dialogs/Context.tsx b/src/components/dialogs/Context.tsx index 1ee4d2739..8c700cafe 100644 --- a/src/components/dialogs/Context.tsx +++ b/src/components/dialogs/Context.tsx @@ -1,5 +1,6 @@ import {createContext, useContext, useMemo, useState} from 'react' +import {type AgeAssuranceRedirectDialogState} from '#/components/ageAssurance/AgeAssuranceRedirectDialog' import * as Dialog from '#/components/Dialog' import {type Screen} from '#/components/dialogs/EmailDialog/types' @@ -22,6 +23,7 @@ type ControlsContext = { displayText: string share?: boolean }> + ageAssuranceRedirectDialogControl: StatefulControl<AgeAssuranceRedirectDialogState> } const ControlsContext = createContext<ControlsContext | null>(null) @@ -46,6 +48,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) { displayText: string share?: boolean }>() + const ageAssuranceRedirectDialogControl = + useStatefulDialogControl<AgeAssuranceRedirectDialogState>() const ctx = useMemo<ControlsContext>( () => ({ @@ -54,6 +58,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { inAppBrowserConsentControl, emailDialogControl, linkWarningDialogControl, + ageAssuranceRedirectDialogControl, }), [ mutedWordsDialogControl, @@ -61,6 +66,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { inAppBrowserConsentControl, emailDialogControl, linkWarningDialogControl, + ageAssuranceRedirectDialogControl, ], ) diff --git a/src/components/moderation/LabelPreference.tsx b/src/components/moderation/LabelPreference.tsx index a951e5abf..edbb12d0c 100644 --- a/src/components/moderation/LabelPreference.tsx +++ b/src/components/moderation/LabelPreference.tsx @@ -1,8 +1,11 @@ -import React from 'react' import {View} from 'react-native' -import {InterpretedLabelValueDefinition, LabelPreference} from '@atproto/api' +import { + type InterpretedLabelValueDefinition, + type LabelPreference, +} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' +import type React from 'react' import {useGlobalLabelStrings} from '#/lib/moderation/useGlobalLabelStrings' import {useLabelBehaviorDescription} from '#/lib/moderation/useLabelBehaviorDescription' @@ -65,6 +68,7 @@ export function Buttons({ ignoreLabel, warnLabel, hideLabel, + disabled, }: { name: string values: ToggleButton.GroupProps['values'] @@ -72,12 +76,14 @@ export function Buttons({ ignoreLabel?: string warnLabel?: string hideLabel?: string + disabled?: boolean }) { const {_} = useLingui() return ( <View style={[{minHeight: 35}, a.w_full]}> <ToggleButton.Group + disabled={disabled} label={_( msg`Configure content filtering setting for category: ${name}`, )} @@ -143,22 +149,21 @@ export function GlobalLabelPreference({ name={labelStrings.name} description={labelStrings.description} /> - {!disabled && ( - <Buttons - name={labelStrings.name.toLowerCase()} - values={[pref]} - onChange={values => { - mutate({ - label: identifier, - visibility: values[0] as LabelPreference, - labelerDid: undefined, - }) - }} - ignoreLabel={labelOptions.ignore} - warnLabel={labelOptions.warn} - hideLabel={labelOptions.hide} - /> - )} + <Buttons + name={labelStrings.name.toLowerCase()} + values={[pref]} + onChange={values => { + mutate({ + label: identifier, + visibility: values[0] as LabelPreference, + labelerDid: undefined, + }) + }} + ignoreLabel={labelOptions.ignore} + warnLabel={labelOptions.warn} + hideLabel={labelOptions.hide} + disabled={disabled} + /> </Outer> ) } diff --git a/src/lib/constants.ts b/src/lib/constants.ts index a21b92de5..3f0d49989 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -202,5 +202,8 @@ export const urls = { }, } +export const PUBLIC_APPVIEW = 'https://api.bsky.app' export const PUBLIC_APPVIEW_DID = 'did:web:api.bsky.app' export const PUBLIC_STAGING_APPVIEW_DID = 'did:web:api.staging.bsky.dev' + +export const DEV_ENV_APPVIEW = `http://localhost:2584` // always the same diff --git a/src/lib/hooks/useIntentHandler.ts b/src/lib/hooks/useIntentHandler.ts index 4a5653750..6b1083aa4 100644 --- a/src/lib/hooks/useIntentHandler.ts +++ b/src/lib/hooks/useIntentHandler.ts @@ -6,10 +6,14 @@ import {logEvent} from '#/lib/statsig/statsig' import {isNative} from '#/platform/detection' import {useSession} from '#/state/session' import {useCloseAllActiveElements} from '#/state/util' +import { + parseAgeAssuranceRedirectDialogState, + useAgeAssuranceRedirectDialogControl, +} from '#/components/ageAssurance/AgeAssuranceRedirectDialog' import {useIntentDialogs} from '#/components/intents/IntentDialogs' import {Referrer} from '../../../modules/expo-bluesky-swiss-army' -type IntentType = 'compose' | 'verify-email' +type IntentType = 'compose' | 'verify-email' | 'age-assurance' const VALID_IMAGE_REGEX = /^[\w.:\-_/]+\|\d+(\.\d+)?\|\d+(\.\d+)?$/ @@ -20,6 +24,9 @@ export function useIntentHandler() { const incomingUrl = Linking.useURL() const composeIntent = useComposeIntent() const verifyEmailIntent = useVerifyEmailIntent() + const ageAssuranceRedirectDialogControl = + useAgeAssuranceRedirectDialogControl() + const {currentAccount} = useSession() React.useEffect(() => { const handleIncomingURL = (url: string) => { @@ -65,6 +72,26 @@ export function useIntentHandler() { verifyEmailIntent(code) return } + case 'age-assurance': { + const state = parseAgeAssuranceRedirectDialogState({ + result: params.get('result') ?? undefined, + actorDid: params.get('actorDid') ?? undefined, + }) + + /* + * If we don't have an account or the account doesn't match, do + * nothing. By the time the user switches to their other account, AA + * state should be ready for them. + */ + if ( + state && + currentAccount && + state.actorDid === currentAccount.did + ) { + ageAssuranceRedirectDialogControl.open(state) + } + return + } default: { return } @@ -78,7 +105,13 @@ export function useIntentHandler() { handleIncomingURL(incomingUrl) previousIntentUrl = incomingUrl } - }, [incomingUrl, composeIntent, verifyEmailIntent]) + }, [ + incomingUrl, + composeIntent, + verifyEmailIntent, + ageAssuranceRedirectDialogControl, + currentAccount, + ]) } export function useComposeIntent() { @@ -97,7 +130,6 @@ export function useComposeIntent() { videoUri: string | null }) => { if (!hasSession) return - closeAllActiveElements() // Whenever a video URI is present, we don't support adding images right now. diff --git a/src/lib/hooks/useTLDs.ts b/src/lib/hooks/useTLDs.ts new file mode 100644 index 000000000..8ed872835 --- /dev/null +++ b/src/lib/hooks/useTLDs.ts @@ -0,0 +1,15 @@ +import {useEffect, useState} from 'react' +import type tldts from 'tldts' + +export function useTLDs() { + const [tlds, setTlds] = useState<typeof tldts>() + + useEffect(() => { + // @ts-expect-error - valid path + import('tldts/dist/index.cjs.min.js').then(tlds => { + setTlds(tlds) + }) + }, []) + + return tlds +} diff --git a/src/lib/notifications/notifications.ts b/src/lib/notifications/notifications.ts index 94b3f6de3..0d2f9ed09 100644 --- a/src/lib/notifications/notifications.ts +++ b/src/lib/notifications/notifications.ts @@ -2,12 +2,13 @@ import {useCallback, useEffect} from 'react' import {Platform} from 'react-native' import * as Notifications from 'expo-notifications' import {getBadgeCountAsync, setBadgeCountAsync} from 'expo-notifications' -import {type AtpAgent} from '@atproto/api' +import {type AppBskyNotificationRegisterPush, type AtpAgent} from '@atproto/api' import debounce from 'lodash.debounce' import {PUBLIC_APPVIEW_DID, PUBLIC_STAGING_APPVIEW_DID} from '#/lib/constants' import {logger as notyLogger} from '#/lib/notifications/util' import {isNative} from '#/platform/detection' +import {useAgeAssuranceContext} from '#/state/ageAssurance' import {type SessionAccount, useAgent, useSession} from '#/state/session' import BackgroundNotificationHandler from '#/../modules/expo-background-notification-handler' @@ -19,25 +20,31 @@ async function _registerPushToken({ agent, currentAccount, token, + extra = {}, }: { agent: AtpAgent currentAccount: SessionAccount token: Notifications.DevicePushToken + extra?: { + ageRestricted?: boolean + } }) { try { - await agent.app.bsky.notification.registerPush({ + const payload: AppBskyNotificationRegisterPush.InputSchema = { serviceDid: currentAccount.service?.includes('staging') ? PUBLIC_STAGING_APPVIEW_DID : PUBLIC_APPVIEW_DID, platform: Platform.OS, token: token.data, appId: 'xyz.blueskyweb.app', - }) + ageRestricted: extra.ageRestricted ?? false, + } - notyLogger.debug(`registerPushToken: success`, { - tokenType: token.type, - token: token.data, - }) + notyLogger.debug(`registerPushToken: registering`, {...payload}) + + await agent.app.bsky.notification.registerPush(payload) + + notyLogger.debug(`registerPushToken: success`) } catch (error) { notyLogger.error(`registerPushToken: failed`, {safeMessage: error}) } @@ -61,12 +68,21 @@ export function useRegisterPushToken() { const {currentAccount} = useSession() return useCallback( - ({token}: {token: Notifications.DevicePushToken}) => { + ({ + token, + isAgeRestricted, + }: { + token: Notifications.DevicePushToken + isAgeRestricted: boolean + }) => { if (!currentAccount) return return _registerPushTokenDebounced({ agent, currentAccount, token, + extra: { + ageRestricted: isAgeRestricted, + }, }) }, [agent, currentAccount], @@ -100,33 +116,46 @@ async function getPushToken() { * it fires), so there's a possibility that multiple calls will be made, but * that is acceptable. * - * @see https://github.com/bluesky-social/social-app/pull/4467 * @see https://github.com/expo/expo/issues/28656 * @see https://github.com/expo/expo/issues/29909 + * @see https://github.com/bluesky-social/social-app/pull/4467 */ export function useGetAndRegisterPushToken() { + const {isAgeRestricted} = useAgeAssuranceContext() const registerPushToken = useRegisterPushToken() - return useCallback(async () => { - /** - * This will also fire the listener added via `addPushTokenListener`. That - * listener also handles registration. - */ - const token = await getPushToken() - - notyLogger.debug(`useGetAndRegisterPushToken`, { - token: token ?? 'undefined', - }) + return useCallback( + async ({ + isAgeRestricted: isAgeRestrictedOverride, + }: { + isAgeRestricted?: boolean + } = {}) => { + if (!isNative) return - if (token) { /** - * The listener should have registered the token already, but just in - * case, call the debounced function again. + * This will also fire the listener added via `addPushTokenListener`. That + * listener also handles registration. */ - registerPushToken({token}) - } + const token = await getPushToken() - return token - }, [registerPushToken]) + notyLogger.debug(`useGetAndRegisterPushToken`, { + token: token ?? 'undefined', + }) + + if (token) { + /** + * The listener should have registered the token already, but just in + * case, call the debounced function again. + */ + registerPushToken({ + token, + isAgeRestricted: isAgeRestrictedOverride ?? isAgeRestricted, + }) + } + + return token + }, + [registerPushToken, isAgeRestricted], + ) } /** @@ -140,12 +169,15 @@ export function useNotificationsRegistration() { const {currentAccount} = useSession() const registerPushToken = useRegisterPushToken() const getAndRegisterPushToken = useGetAndRegisterPushToken() + const {isReady: isAgeRestrictionReady, isAgeRestricted} = + useAgeAssuranceContext() useEffect(() => { /** - * We want this to init right away _after_ we have a logged in user. + * We want this to init right away _after_ we have a logged in user, and + * _after_ we've loaded their age assurance state. */ - if (!currentAccount) return + if (!currentAccount || !isAgeRestrictionReady) return notyLogger.debug(`useNotificationsRegistration`) @@ -167,14 +199,20 @@ export function useNotificationsRegistration() { * @see https://docs.expo.dev/versions/latest/sdk/notifications/#addpushtokenlistenerlistener */ const subscription = Notifications.addPushTokenListener(async token => { - registerPushToken({token}) + registerPushToken({token, isAgeRestricted: isAgeRestricted}) notyLogger.debug(`addPushTokenListener callback`, {token}) }) return () => { subscription.remove() } - }, [currentAccount, getAndRegisterPushToken, registerPushToken]) + }, [ + currentAccount, + getAndRegisterPushToken, + registerPushToken, + isAgeRestrictionReady, + isAgeRestricted, + ]) } export function useRequestNotificationsPermission() { diff --git a/src/lib/statsig/gates.ts b/src/lib/statsig/gates.ts index 3b1106480..efd7d605a 100644 --- a/src/lib/statsig/gates.ts +++ b/src/lib/statsig/gates.ts @@ -1,5 +1,6 @@ export type Gate = // Keep this alphabetic please. + | 'age_assurance' | 'alt_share_icon' | 'debug_show_feedcontext' | 'debug_subscriptions' diff --git a/src/logger/types.ts b/src/logger/types.ts index 88d8d9d93..4743e866c 100644 --- a/src/logger/types.ts +++ b/src/logger/types.ts @@ -12,6 +12,7 @@ export enum LogContext { ReportDialog = 'report-dialog', FeedFeedback = 'feed-feedback', PostSource = 'post-source', + AgeAssurance = 'age-assurance', /** * METRIC IS FOR INTERNAL USE ONLY, don't create any other loggers using this diff --git a/src/screens/Messages/ChatList.tsx b/src/screens/Messages/ChatList.tsx index 388d23ec2..e13f0617b 100644 --- a/src/screens/Messages/ChatList.tsx +++ b/src/screens/Messages/ChatList.tsx @@ -23,6 +23,8 @@ import {useSession} from '#/state/session' import {List, type ListRef} from '#/view/com/util/List' import {ChatListLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' import {atoms as a, useBreakpoints, useTheme} from '#/alf' +import {AgeRestrictedScreen} from '#/components/ageAssurance/AgeRestrictedScreen' +import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy' import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {type DialogControlProps, useDialogControl} from '#/components/Dialog' import {NewChat} from '#/components/dms/dialogs/NewChatDialog' @@ -64,7 +66,21 @@ function keyExtractor(item: ListItem) { } type Props = NativeStackScreenProps<MessagesTabNavigatorParams, 'Messages'> -export function MessagesScreen({navigation, route}: Props) { + +export function MessagesScreen(props: Props) { + const {_} = useLingui() + const aaCopy = useAgeAssuranceCopy() + + return ( + <AgeRestrictedScreen + screenTitle={_(msg`Chats`)} + infoText={aaCopy.chatsInfoText}> + <MessagesScreenInner {...props} /> + </AgeRestrictedScreen> + ) +} + +export function MessagesScreenInner({navigation, route}: Props) { const {_} = useLingui() const t = useTheme() const {currentAccount} = useSession() diff --git a/src/screens/Messages/Conversation.tsx b/src/screens/Messages/Conversation.tsx index 90547a8d4..7f3b53b94 100644 --- a/src/screens/Messages/Conversation.tsx +++ b/src/screens/Messages/Conversation.tsx @@ -32,6 +32,8 @@ import {useProfileQuery} from '#/state/queries/profile' import {useSetMinimalShellMode} from '#/state/shell' import {MessagesList} from '#/screens/Messages/components/MessagesList' import {atoms as a, useBreakpoints, useTheme, web} from '#/alf' +import {AgeRestrictedScreen} from '#/components/ageAssurance/AgeRestrictedScreen' +import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy' import { EmailDialogScreenID, useEmailDialogControl, @@ -46,7 +48,20 @@ type Props = NativeStackScreenProps< CommonNavigatorParams, 'MessagesConversation' > -export function MessagesConversationScreen({route}: Props) { + +export function MessagesConversationScreen(props: Props) { + const {_} = useLingui() + const aaCopy = useAgeAssuranceCopy() + return ( + <AgeRestrictedScreen + screenTitle={_(msg`Conversation`)} + infoText={aaCopy.chatsInfoText}> + <MessagesConversationScreenInner {...props} /> + </AgeRestrictedScreen> + ) +} + +export function MessagesConversationScreenInner({route}: Props) { const {gtMobile} = useBreakpoints() const setMinimalShellMode = useSetMinimalShellMode() diff --git a/src/screens/Messages/Inbox.tsx b/src/screens/Messages/Inbox.tsx index 0f64d2014..8765cf0ba 100644 --- a/src/screens/Messages/Inbox.tsx +++ b/src/screens/Messages/Inbox.tsx @@ -1,17 +1,23 @@ import {useCallback, useMemo, useState} from 'react' import {View} from 'react-native' -import {ChatBskyConvoDefs, ChatBskyConvoListConvos} from '@atproto/api' +import { + type ChatBskyConvoDefs, + type ChatBskyConvoListConvos, +} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useFocusEffect, useNavigation} from '@react-navigation/native' -import {InfiniteData, UseInfiniteQueryResult} from '@tanstack/react-query' +import { + type InfiniteData, + type UseInfiniteQueryResult, +} from '@tanstack/react-query' import {useAppState} from '#/lib/hooks/useAppState' import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' import { - CommonNavigatorParams, - NativeStackScreenProps, - NavigationProp, + type CommonNavigatorParams, + type NativeStackScreenProps, + type NavigationProp, } from '#/lib/routes/types' import {cleanError} from '#/lib/strings/errors' import {logger} from '#/logger' @@ -26,6 +32,8 @@ import {List} from '#/view/com/util/List' import {ChatListLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' import * as Toast from '#/view/com/util/Toast' import {atoms as a, useBreakpoints, useTheme} from '#/alf' +import {AgeRestrictedScreen} from '#/components/ageAssurance/AgeRestrictedScreen' +import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy' import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {useRefreshOnFocus} from '#/components/hooks/useRefreshOnFocus' import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeftIcon} from '#/components/icons/Arrow' @@ -39,7 +47,20 @@ import {Text} from '#/components/Typography' import {RequestListItem} from './components/RequestListItem' type Props = NativeStackScreenProps<CommonNavigatorParams, 'MessagesInbox'> -export function MessagesInboxScreen({}: Props) { + +export function MessagesInboxScreen(props: Props) { + const {_} = useLingui() + const aaCopy = useAgeAssuranceCopy() + return ( + <AgeRestrictedScreen + screenTitle={_(msg`Chat requests`)} + infoText={aaCopy.chatsInfoText}> + <MessagesInboxScreenInner {...props} /> + </AgeRestrictedScreen> + ) +} + +export function MessagesInboxScreenInner({}: Props) { const {gtTablet} = useBreakpoints() const listConvosQuery = useListConvosQuery({status: 'request'}) diff --git a/src/screens/Messages/Settings.tsx b/src/screens/Messages/Settings.tsx index 0b8c88b9d..6015c07cd 100644 --- a/src/screens/Messages/Settings.tsx +++ b/src/screens/Messages/Settings.tsx @@ -12,6 +12,8 @@ import {useSession} from '#/state/session' import * as Toast from '#/view/com/util/Toast' import {atoms as a} from '#/alf' import {Admonition} from '#/components/Admonition' +import {AgeRestrictedScreen} from '#/components/ageAssurance/AgeRestrictedScreen' +import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy' import {Divider} from '#/components/Divider' import * as Toggle from '#/components/forms/Toggle' import * as Layout from '#/components/Layout' @@ -21,7 +23,21 @@ import {useBackgroundNotificationPreferences} from '../../../modules/expo-backgr type AllowIncoming = 'all' | 'none' | 'following' type Props = NativeStackScreenProps<CommonNavigatorParams, 'MessagesSettings'> -export function MessagesSettingsScreen({}: Props) { + +export function MessagesSettingsScreen(props: Props) { + const {_} = useLingui() + const aaCopy = useAgeAssuranceCopy() + + return ( + <AgeRestrictedScreen + screenTitle={_(msg`Chat Settings`)} + infoText={aaCopy.chatsInfoText}> + <MessagesSettingsScreenInner {...props} /> + </AgeRestrictedScreen> + ) +} + +export function MessagesSettingsScreenInner({}: Props) { const {_} = useLingui() const {currentAccount} = useSession() const {data: profile} = useProfileQuery({ diff --git a/src/screens/Moderation/index.tsx b/src/screens/Moderation/index.tsx index 78b0a6ae9..a7b434e52 100644 --- a/src/screens/Moderation/index.tsx +++ b/src/screens/Moderation/index.tsx @@ -12,6 +12,7 @@ import { } from '#/lib/routes/types' import {logger} from '#/logger' import {isIOS} from '#/platform/detection' +import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance' import { useMyLabelersQuery, usePreferencesQuery, @@ -20,8 +21,8 @@ import { } from '#/state/queries/preferences' import {isNonConfigurableModerationAuthority} from '#/state/session/additional-moderation-authorities' import {useSetMinimalShellMode} from '#/state/shell' -import {ViewHeader} from '#/view/com/util/ViewHeader' import {atoms as a, useBreakpoints, useTheme, type ViewStyleProp} from '#/alf' +import {AgeAssuranceAdmonition} from '#/components/ageAssurance/AgeAssuranceAdmonition' import {Button, ButtonText} from '#/components/Button' import * as Dialog from '#/components/Dialog' import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings' @@ -84,13 +85,22 @@ export function ModerationScreen( error: preferencesError, data: preferences, } = usePreferencesQuery() + const {isReady: isAgeInfoReady} = useAgeAssurance() - const isLoading = isPreferencesLoading + const isLoading = isPreferencesLoading || !isAgeInfoReady const error = preferencesError return ( <Layout.Screen testID="moderationScreen"> - <ViewHeader title={_(msg`Moderation`)} showOnDesktop /> + <Layout.Header.Outer> + <Layout.Header.BackButton /> + <Layout.Header.Content> + <Layout.Header.TitleText> + <Trans>Moderation</Trans> + </Layout.Header.TitleText> + </Layout.Header.Content> + <Layout.Header.Slot /> + </Layout.Header.Outer> <Layout.Content> {isLoading ? ( <ListMaybePlaceholder isLoading={true} sideBorders={false} /> @@ -157,6 +167,7 @@ export function ModerationScreenInner({ data: labelers, error: labelersError, } = useMyLabelersQuery() + const {declaredAge, isDeclaredUnderage, isAgeRestricted} = useAgeAssurance() useFocusEffect( useCallback(() => { @@ -170,8 +181,6 @@ export function ModerationScreenInner({ (optimisticAdultContent && optimisticAdultContent.enabled) || (!optimisticAdultContent && preferences.moderationPrefs.adultContentEnabled) ) - const ageNotSet = !preferences.userAge - const isUnderage = (preferences.userAge || 0) < 18 const onToggleAdultContentEnabled = useCallback( async (selected: boolean) => { @@ -306,8 +315,14 @@ export function ModerationScreenInner({ <Trans>Content filters</Trans> </Text> + <AgeAssuranceAdmonition style={[a.pb_md]}> + <Trans> + You must complete age assurance in order to access the settings below. + </Trans> + </AgeAssuranceAdmonition> + <View style={[a.gap_md]}> - {ageNotSet && ( + {declaredAge === undefined && ( <> <Button label={_(msg`Confirm your birthdate`)} @@ -336,7 +351,7 @@ export function ModerationScreenInner({ a.overflow_hidden, t.atoms.bg_contrast_25, ]}> - {!ageNotSet && !isUnderage && ( + {!isDeclaredUnderage && !isAgeRestricted && ( <> <View style={[ @@ -389,21 +404,25 @@ export function ModerationScreenInner({ </View> )} <Divider /> + + {adultContentEnabled && ( + <> + <GlobalLabelPreference labelDefinition={LABELS.porn} /> + <Divider /> + <GlobalLabelPreference labelDefinition={LABELS.sexual} /> + <Divider /> + <GlobalLabelPreference + labelDefinition={LABELS['graphic-media']} + /> + <Divider /> + </> + )} </> )} - {!isUnderage && adultContentEnabled && ( - <> - <GlobalLabelPreference labelDefinition={LABELS.porn} /> - <Divider /> - <GlobalLabelPreference labelDefinition={LABELS.sexual} /> - <Divider /> - <GlobalLabelPreference - labelDefinition={LABELS['graphic-media']} - /> - <Divider /> - </> - )} - <GlobalLabelPreference labelDefinition={LABELS.nudity} /> + <GlobalLabelPreference + disabled={isDeclaredUnderage || isAgeRestricted} + labelDefinition={LABELS.nudity} + /> </View> </View> diff --git a/src/screens/Settings/AboutSettings.tsx b/src/screens/Settings/AboutSettings.tsx index 0ce127ff3..6be881a88 100644 --- a/src/screens/Settings/AboutSettings.tsx +++ b/src/screens/Settings/AboutSettings.tsx @@ -20,9 +20,11 @@ import {BroomSparkle_Stroke2_Corner2_Rounded as BroomSparkleIcon} from '#/compon import {CodeLines_Stroke2_Corner2_Rounded as CodeLinesIcon} from '#/components/icons/CodeLines' import {Globe_Stroke2_Corner0_Rounded as GlobeIcon} from '#/components/icons/Globe' import {Newspaper_Stroke2_Corner2_Rounded as NewspaperIcon} from '#/components/icons/Newspaper' +import {ShieldCheck_Stroke2_Corner0_Rounded as Shield} from '#/components/icons/Shield' import {Wrench_Stroke2_Corner2_Rounded as WrenchIcon} from '#/components/icons/Wrench' import * as Layout from '#/components/Layout' import {Loader} from '#/components/Loader' +import {device} from '#/storage' import {useDemoMode} from '#/storage/hooks/demo-mode' import {useDevMode} from '#/storage/hooks/dev-mode' import {OTAInfo} from './components/OTAInfo' @@ -179,6 +181,20 @@ export function AboutSettingsScreen({}: Props) { </SettingsList.ItemText> </SettingsList.PressableItem> )} + + <SettingsList.PressableItem + onPress={() => { + device.set(['geolocation'], { + countryCode: 'GB', + isAgeRestrictedGeo: true, + }) + }} + label="Simulate age restriction"> + <SettingsList.ItemIcon icon={Shield} /> + <SettingsList.ItemText> + Simulate age restriction + </SettingsList.ItemText> + </SettingsList.PressableItem> </> )} </SettingsList.Container> diff --git a/src/screens/Settings/AccountSettings.tsx b/src/screens/Settings/AccountSettings.tsx index 393bad2f8..86652d277 100644 --- a/src/screens/Settings/AccountSettings.tsx +++ b/src/screens/Settings/AccountSettings.tsx @@ -7,6 +7,7 @@ import {useModalControls} from '#/state/modals' import {useSession} from '#/state/session' import * as SettingsList from '#/screens/Settings/components/SettingsList' import {atoms as a, useTheme} from '#/alf' +import {AgeAssuranceAccountCard} from '#/components/ageAssurance/AgeAssuranceAccountCard' import {useDialogControl} from '#/components/Dialog' import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings' import { @@ -114,16 +115,6 @@ export function AccountSettingsScreen({}: Props) { <SettingsList.Chevron /> </SettingsList.PressableItem> <SettingsList.Divider /> - <SettingsList.Item> - <SettingsList.ItemIcon icon={BirthdayCakeIcon} /> - <SettingsList.ItemText> - <Trans>Birthday</Trans> - </SettingsList.ItemText> - <SettingsList.BadgeButton - label={_(msg`Edit`)} - onPress={() => birthdayControl.open()} - /> - </SettingsList.Item> <SettingsList.PressableItem label={_(msg`Password`)} onPress={() => openModal({name: 'change-password'})}> @@ -143,6 +134,17 @@ export function AccountSettingsScreen({}: Props) { </SettingsList.ItemText> <SettingsList.Chevron /> </SettingsList.PressableItem> + <SettingsList.Item> + <SettingsList.ItemIcon icon={BirthdayCakeIcon} /> + <SettingsList.ItemText> + <Trans>Birthday</Trans> + </SettingsList.ItemText> + <SettingsList.BadgeButton + label={_(msg`Edit`)} + onPress={() => birthdayControl.open()} + /> + </SettingsList.Item> + <AgeAssuranceAccountCard style={[a.px_xl, a.pt_xs, a.pb_md]} /> <SettingsList.Divider /> <SettingsList.PressableItem label={_(msg`Export my data`)} diff --git a/src/screens/Settings/Settings.tsx b/src/screens/Settings/Settings.tsx index aaba0b4b5..4d10a9d0d 100644 --- a/src/screens/Settings/Settings.tsx +++ b/src/screens/Settings/Settings.tsx @@ -32,6 +32,7 @@ import * as Toast from '#/view/com/util/Toast' import {UserAvatar} from '#/view/com/util/UserAvatar' import * as SettingsList from '#/screens/Settings/components/SettingsList' import {atoms as a, platform, tokens, useBreakpoints, useTheme} from '#/alf' +import {AgeAssuranceDismissibleNotice} from '#/components/ageAssurance/AgeAssuranceDismissibleNotice' import {AvatarStackWithFetch} from '#/components/AvatarStack' import {useDialogControl} from '#/components/Dialog' import {SwitchAccountDialog} from '#/components/dialogs/SwitchAccount' @@ -96,6 +97,8 @@ export function SettingsScreen({}: Props) { </Layout.Header.Outer> <Layout.Content> <SettingsList.Container> + <AgeAssuranceDismissibleNotice style={[a.px_lg, a.pt_xs, a.pb_xl]} /> + <View style={[ a.px_xl, diff --git a/src/state/ageAssurance/const.ts b/src/state/ageAssurance/const.ts new file mode 100644 index 000000000..2f329582a --- /dev/null +++ b/src/state/ageAssurance/const.ts @@ -0,0 +1,11 @@ +import {type ModerationPrefs} from '@atproto/api' + +import {DEFAULT_LOGGED_OUT_LABEL_PREFERENCES} from '#/state/queries/preferences/moderation' + +export const AGE_RESTRICTED_MODERATION_PREFS: ModerationPrefs = { + adultContentEnabled: false, + labels: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES, + labelers: [], + mutedWords: [], + hiddenPosts: [], +} diff --git a/src/state/ageAssurance/index.tsx b/src/state/ageAssurance/index.tsx new file mode 100644 index 000000000..aab954e6c --- /dev/null +++ b/src/state/ageAssurance/index.tsx @@ -0,0 +1,140 @@ +import {createContext, useContext, useMemo} from 'react' +import {type AppBskyUnspeccedDefs} from '@atproto/api' +import {useQuery} from '@tanstack/react-query' + +import {networkRetry} from '#/lib/async/retry' +import {useGetAndRegisterPushToken} from '#/lib/notifications/notifications' +import {useGate} from '#/lib/statsig/statsig' +import {isNetworkError} from '#/lib/strings/errors' +import {Logger} from '#/logger' +import { + type AgeAssuranceAPIContextType, + type AgeAssuranceContextType, +} from '#/state/ageAssurance/types' +import {useIsAgeAssuranceEnabled} from '#/state/ageAssurance/useIsAgeAssuranceEnabled' +import {useGeolocation} from '#/state/geolocation' +import {useAgent} from '#/state/session' + +const logger = Logger.create(Logger.Context.AgeAssurance) + +export const createAgeAssuranceQueryKey = (did: string) => + ['ageAssurance', did] as const + +const DEFAULT_AGE_ASSURANCE_STATE: AppBskyUnspeccedDefs.AgeAssuranceState = { + lastInitiatedAt: undefined, + status: 'unknown', +} + +const AgeAssuranceContext = createContext<AgeAssuranceContextType>({ + status: 'unknown', + isReady: false, + lastInitiatedAt: undefined, + isAgeRestricted: false, +}) + +const AgeAssuranceAPIContext = createContext<AgeAssuranceAPIContextType>({ + // @ts-ignore can't be bothered to type this + refetch: () => Promise.resolve(), +}) + +/** + * Low-level provider for fetching age assurance state on app load. Do not add + * any other data fetching in here to avoid complications and reduced + * performance. + */ +export function Provider({children}: {children: React.ReactNode}) { + const gate = useGate() + const agent = useAgent() + const {geolocation} = useGeolocation() + const isAgeAssuranceEnabled = useIsAgeAssuranceEnabled() + const getAndRegisterPushToken = useGetAndRegisterPushToken() + + const {data, isFetched, refetch} = useQuery({ + /** + * This is load bearing. We always want this query to run and end in a + * "fetched" state, even if we fall back to defaults. This lets the rest of + * the app know that we've at least attempted to load the AA state. + * + * However, it only needs to run if AA is enabled. + */ + enabled: isAgeAssuranceEnabled, + queryKey: createAgeAssuranceQueryKey(agent.session?.did ?? 'never'), + async queryFn() { + if (!agent.session) return null + + try { + const {data} = await networkRetry(3, () => + agent.app.bsky.unspecced.getAgeAssuranceState(), + ) + // const {data} = { + // data: { + // lastInitiatedAt: new Date().toISOString(), + // status: 'pending', + // } as AppBskyUnspeccedDefs.AgeAssuranceState, + // } + + logger.debug(`fetch`, { + data, + account: agent.session?.did, + }) + + if (gate('age_assurance')) { + await getAndRegisterPushToken({ + isAgeRestricted: + !!geolocation?.isAgeRestrictedGeo && data.status !== 'assured', + }) + } + + return data + } catch (e) { + if (!isNetworkError(e)) { + logger.error(`ageAssurance: failed to fetch`, {safeMessage: e}) + } + // don't re-throw error, we'll just fall back to defaults + return null + } + }, + }) + + /** + * Derive state, or fall back to defaults + */ + const ageAssuranceContext = useMemo<AgeAssuranceContextType>(() => { + const {status, lastInitiatedAt} = data || DEFAULT_AGE_ASSURANCE_STATE + const ctx: AgeAssuranceContextType = { + isReady: isFetched || !isAgeAssuranceEnabled, + status, + lastInitiatedAt, + isAgeRestricted: isAgeAssuranceEnabled ? status !== 'assured' : false, + } + logger.debug(`context`, ctx) + return ctx + }, [isFetched, data, isAgeAssuranceEnabled]) + + const ageAssuranceAPIContext = useMemo<AgeAssuranceAPIContextType>( + () => ({ + refetch, + }), + [refetch], + ) + + return ( + <AgeAssuranceAPIContext.Provider value={ageAssuranceAPIContext}> + <AgeAssuranceContext.Provider value={ageAssuranceContext}> + {children} + </AgeAssuranceContext.Provider> + </AgeAssuranceAPIContext.Provider> + ) +} + +/** + * Access to low-level AA state. Prefer using {@link useAgeInfo} for a + * more user-friendly interface. + */ +export function useAgeAssuranceContext() { + return useContext(AgeAssuranceContext) +} + +export function useAgeAssuranceAPIContext() { + return useContext(AgeAssuranceAPIContext) +} diff --git a/src/state/ageAssurance/types.ts b/src/state/ageAssurance/types.ts new file mode 100644 index 000000000..63febb3cf --- /dev/null +++ b/src/state/ageAssurance/types.ts @@ -0,0 +1,33 @@ +import {type AppBskyUnspeccedDefs} from '@atproto/api' +import {type QueryObserverBaseResult} from '@tanstack/react-query' + +export type AgeAssuranceContextType = { + /** + * Whether the age assurance state has been fetched from the server. If user + * is not in a region that requires AA, or AA is otherwise disabled, this + * will always be `true`. + */ + isReady: boolean + /** + * The server-reported status of the user's age verification process. + */ + status: AppBskyUnspeccedDefs.AgeAssuranceState['status'] + /** + * The last time the age assurance state was attempted by the user. + */ + lastInitiatedAt: AppBskyUnspeccedDefs.AgeAssuranceState['lastInitiatedAt'] + /** + * Indicates the user is age restricted based on the requirements of their + * region, and their server-provided age assurance status. Does not factor in + * the user's declared age. If AA is otherise disabled, this will always be + * `false`. + */ + isAgeRestricted: boolean +} + +export type AgeAssuranceAPIContextType = { + /** + * Refreshes the age assurance state by fetching it from the server. + */ + refetch: QueryObserverBaseResult['refetch'] +} diff --git a/src/state/ageAssurance/useAgeAssurance.ts b/src/state/ageAssurance/useAgeAssurance.ts new file mode 100644 index 000000000..455f38c92 --- /dev/null +++ b/src/state/ageAssurance/useAgeAssurance.ts @@ -0,0 +1,45 @@ +import {useMemo} from 'react' + +import {Logger} from '#/logger' +import {useAgeAssuranceContext} from '#/state/ageAssurance' +import {usePreferencesQuery} from '#/state/queries/preferences' + +const logger = Logger.create(Logger.Context.AgeAssurance) + +type AgeAssurance = ReturnType<typeof useAgeAssuranceContext> & { + /** + * The age the user has declared in their preferences, if any. + */ + declaredAge: number | undefined + /** + * Indicates whether the user has declared an age under 18. + */ + isDeclaredUnderage: boolean +} + +/** + * Computed age information based on age assurance status and the user's + * declared age. Use this instead of {@link useAgeAssuranceContext} to get a + * more user-friendly interface. + */ +export function useAgeAssurance(): AgeAssurance { + const aa = useAgeAssuranceContext() + const {isFetched: preferencesLoaded, data: preferences} = + usePreferencesQuery() + const declaredAge = preferences?.userAge + + return useMemo(() => { + const isReady = aa.isReady && preferencesLoaded + const isDeclaredUnderage = (declaredAge || 0) < 18 + const state: AgeAssurance = { + isReady, + status: aa.status, + lastInitiatedAt: aa.lastInitiatedAt, + isAgeRestricted: aa.isAgeRestricted, + declaredAge, + isDeclaredUnderage, + } + logger.debug(`state`, state) + return state + }, [aa, preferencesLoaded, declaredAge]) +} diff --git a/src/state/ageAssurance/useInitAgeAssurance.ts b/src/state/ageAssurance/useInitAgeAssurance.ts new file mode 100644 index 000000000..8776dd29c --- /dev/null +++ b/src/state/ageAssurance/useInitAgeAssurance.ts @@ -0,0 +1,85 @@ +import { + type AppBskyUnspeccedDefs, + type AppBskyUnspeccedInitAgeAssurance, + AtpAgent, +} from '@atproto/api' +import {useMutation, useQueryClient} from '@tanstack/react-query' + +import {wait} from '#/lib/async/wait' +import { + // DEV_ENV_APPVIEW, + PUBLIC_APPVIEW, + PUBLIC_APPVIEW_DID, +} from '#/lib/constants' +import {isNetworkError} from '#/lib/hooks/useCleanError' +import {logger} from '#/logger' +import {createAgeAssuranceQueryKey} from '#/state/ageAssurance' +import {useGeolocation} from '#/state/geolocation' +import {useAgent} from '#/state/session' + +let APPVIEW = PUBLIC_APPVIEW +let APPVIEW_DID = PUBLIC_APPVIEW_DID + +/* + * Uncomment if using the local dev-env + */ +// if (__DEV__) { +// APPVIEW = DEV_ENV_APPVIEW +// /* +// * IMPORTANT: you need to get this value from `http://localhost:2581` +// * introspection endpoint and updated in `constants`, since it changes +// * every time you run the dev-env. +// */ +// APPVIEW_DID = `` +// } + +export function useInitAgeAssurance() { + const qc = useQueryClient() + const agent = useAgent() + const {geolocation} = useGeolocation() + return useMutation({ + async mutationFn( + props: Omit<AppBskyUnspeccedInitAgeAssurance.InputSchema, 'countryCode'>, + ) { + if (!geolocation?.countryCode) { + throw new Error(`Geolocation not available, cannot init age assurance.`) + } + + const { + data: {token}, + } = await agent.com.atproto.server.getServiceAuth({ + aud: APPVIEW_DID, + lxm: `app.bsky.unspecced.initAgeAssurance`, + }) + + const appView = new AtpAgent({service: APPVIEW}) + appView.sessionManager.session = {...agent.session!} + appView.sessionManager.session.accessJwt = token + appView.sessionManager.session.refreshJwt = '' + + /* + * 2s wait is good actually. Email sending takes a hot sec and this helps + * ensure the email is ready for the user once they open their inbox. + */ + const {data} = await wait( + 2e3, + appView.app.bsky.unspecced.initAgeAssurance({ + ...props, + countryCode: geolocation?.countryCode?.toUpperCase(), + }), + ) + + qc.setQueryData<AppBskyUnspeccedDefs.AgeAssuranceState>( + createAgeAssuranceQueryKey(agent.session?.did ?? 'never'), + () => data, + ) + }, + onError(e) { + if (!isNetworkError(e)) { + logger.error(`useInitAgeAssurance failed`, { + safeMessage: e, + }) + } + }, + }) +} diff --git a/src/state/ageAssurance/useIsAgeAssuranceEnabled.ts b/src/state/ageAssurance/useIsAgeAssuranceEnabled.ts new file mode 100644 index 000000000..5c1a7b1c4 --- /dev/null +++ b/src/state/ageAssurance/useIsAgeAssuranceEnabled.ts @@ -0,0 +1,13 @@ +import {useMemo} from 'react' + +import {useGate} from '#/lib/statsig/statsig' +import {useGeolocation} from '#/state/geolocation' + +export function useIsAgeAssuranceEnabled() { + const gate = useGate() + const {geolocation} = useGeolocation() + + return useMemo(() => { + return gate('age_assurance') && !!geolocation?.isAgeRestrictedGeo + }, [geolocation, gate]) +} diff --git a/src/state/geolocation.tsx b/src/state/geolocation.tsx index 83a42f21d..20b161ffe 100644 --- a/src/state/geolocation.tsx +++ b/src/state/geolocation.tsx @@ -25,6 +25,7 @@ const onGeolocationUpdate = ( */ export const DEFAULT_GEOLOCATION: Device['geolocation'] = { countryCode: undefined, + isAgeRestrictedGeo: false, } async function getGeolocation(): Promise<Device['geolocation']> { @@ -39,6 +40,7 @@ async function getGeolocation(): Promise<Device['geolocation']> { if (json.countryCode) { return { countryCode: json.countryCode, + isAgeRestrictedGeo: json.isAgeRestrictedGeo ?? false, } } else { return undefined @@ -66,7 +68,9 @@ export function beginResolveGeolocation() { */ if (__DEV__) { geolocationResolution = new Promise(y => y({success: true})) - device.set(['geolocation'], DEFAULT_GEOLOCATION) + if (!device.get(['geolocation'])) { + device.set(['geolocation'], DEFAULT_GEOLOCATION) + } return } diff --git a/src/state/queries/nuxs/definitions.ts b/src/state/queries/nuxs/definitions.ts index 1947f857f..61657992f 100644 --- a/src/state/queries/nuxs/definitions.ts +++ b/src/state/queries/nuxs/definitions.ts @@ -7,6 +7,8 @@ export enum Nux { ExploreInterestsCard = 'ExploreInterestsCard', InitialVerificationAnnouncement = 'InitialVerificationAnnouncement', ActivitySubscriptions = 'ActivitySubscriptions', + AgeAssuranceDismissibleNotice = 'AgeAssuranceDismissibleNotice', + AgeAssuranceDismissibleHeaderButton = 'AgeAssuranceDismissibleHeaderButton', } export const nuxNames = new Set(Object.values(Nux)) @@ -28,6 +30,14 @@ export type AppNux = BaseNux< id: Nux.ActivitySubscriptions data: undefined } + | { + id: Nux.AgeAssuranceDismissibleNotice + data: undefined + } + | { + id: Nux.AgeAssuranceDismissibleHeaderButton + data: undefined + } > export const NuxSchemas: Record<Nux, zod.ZodObject<any> | undefined> = { @@ -35,4 +45,6 @@ export const NuxSchemas: Record<Nux, zod.ZodObject<any> | undefined> = { [Nux.ExploreInterestsCard]: undefined, [Nux.InitialVerificationAnnouncement]: undefined, [Nux.ActivitySubscriptions]: undefined, + [Nux.AgeAssuranceDismissibleNotice]: undefined, + [Nux.AgeAssuranceDismissibleHeaderButton]: undefined, } diff --git a/src/state/queries/nuxs/index.ts b/src/state/queries/nuxs/index.ts index 6ad59c7a4..b9650d057 100644 --- a/src/state/queries/nuxs/index.ts +++ b/src/state/queries/nuxs/index.ts @@ -1,6 +1,6 @@ import {useMutation, useQueryClient} from '@tanstack/react-query' -import {AppNux, Nux} from '#/state/queries/nuxs/definitions' +import {type AppNux, type Nux} from '#/state/queries/nuxs/definitions' import {parseAppNux, serializeAppNux} from '#/state/queries/nuxs/util' import { preferencesQueryKey, @@ -40,6 +40,20 @@ export function useNuxs(): } } + // if (__DEV__) { + // const queryClient = useQueryClient() + // const agent = useAgent() + + // // @ts-ignore + // window.clearNux = async (ids: string[]) => { + // await agent.bskyAppRemoveNuxs(ids) + // // triggers a refetch + // await queryClient.invalidateQueries({ + // queryKey: preferencesQueryKey, + // }) + // } + // } + return { nuxs: undefined, status, diff --git a/src/state/queries/post-feed.ts b/src/state/queries/post-feed.ts index 361081e67..22e95fcd6 100644 --- a/src/state/queries/post-feed.ts +++ b/src/state/queries/post-feed.ts @@ -8,6 +8,7 @@ import { type BskyAgent, moderatePost, type ModerationDecision, + type ModerationPrefs, } from '@atproto/api' import { type InfiniteData, @@ -31,6 +32,7 @@ import {FeedTuner, type FeedTunerFn} from '#/lib/api/feed-manip' import {DISCOVER_FEED_URI} from '#/lib/constants' import {BSKY_FEED_OWNER_DIDS} from '#/lib/constants' import {logger} from '#/logger' +import {useAgeAssuranceContext} from '#/state/ageAssurance' import {STALE} from '#/state/queries' import {DEFAULT_LOGGED_OUT_PREFERENCES} from '#/state/queries/preferences/const' import {useAgent} from '#/state/session' @@ -134,8 +136,18 @@ export function usePostFeedQuery( const feedTuners = useFeedTuners(feedDesc) const moderationOpts = useModerationOpts() const {data: preferences} = usePreferencesQuery() + /** + * Load bearing: we need to await AA state or risk FOUC. This marginally + * delays feeds, but AA state is fetched immediately on load and is then + * available for the remainder of the session, so this delay only affects cold + * loads. -esb + */ + const {isReady: isAgeAssuranceReady} = useAgeAssuranceContext() const enabled = - opts?.enabled !== false && Boolean(moderationOpts) && Boolean(preferences) + opts?.enabled !== false && + Boolean(moderationOpts) && + Boolean(preferences) && + isAgeAssuranceReady const userInterests = aggregateUserInterests(preferences) const followingPinnedIndex = preferences?.savedFeeds?.findIndex( @@ -206,7 +218,11 @@ export function usePostFeedQuery( * some not. */ if (!agent.session) { - assertSomePostsPassModeration(res.feed) + assertSomePostsPassModeration( + res.feed, + preferences?.moderationPrefs || + DEFAULT_LOGGED_OUT_PREFERENCES.moderationPrefs, + ) } return { @@ -596,7 +612,10 @@ export function* findAllProfilesInQueryData( } } -function assertSomePostsPassModeration(feed: AppBskyFeedDefs.FeedViewPost[]) { +function assertSomePostsPassModeration( + feed: AppBskyFeedDefs.FeedViewPost[], + moderationPrefs: ModerationPrefs, +) { // no posts in this feed if (feed.length === 0) return true @@ -606,7 +625,7 @@ function assertSomePostsPassModeration(feed: AppBskyFeedDefs.FeedViewPost[]) { for (const item of feed) { const moderation = moderatePost(item.post, { userDid: undefined, - prefs: DEFAULT_LOGGED_OUT_PREFERENCES.moderationPrefs, + prefs: moderationPrefs, }) if (!moderation.ui('contentList').filter) { diff --git a/src/state/queries/preferences/index.ts b/src/state/queries/preferences/index.ts index e64f117e6..44d63b55c 100644 --- a/src/state/queries/preferences/index.ts +++ b/src/state/queries/preferences/index.ts @@ -1,3 +1,4 @@ +import {useCallback} from 'react' import { type AppBskyActorDefs, type BskyFeedViewPreference, @@ -9,6 +10,8 @@ import {PROD_DEFAULT_FEED} from '#/lib/constants' import {replaceEqualDeep} from '#/lib/functions' import {getAge} from '#/lib/strings/time' import {logger} from '#/logger' +import {useAgeAssuranceContext} from '#/state/ageAssurance' +import {AGE_RESTRICTED_MODERATION_PREFS} from '#/state/ageAssurance/const' import {STALE} from '#/state/queries' import { DEFAULT_HOME_FEED_PREFS, @@ -31,6 +34,8 @@ export const preferencesQueryKey = [preferencesQueryKeyRoot] export function usePreferencesQuery() { const agent = useAgent() + const {isAgeRestricted} = useAgeAssuranceContext() + return useQuery({ staleTime: STALE.SECONDS.FIFTEEN, structuralSharing: replaceEqualDeep, @@ -68,6 +73,16 @@ export function usePreferencesQuery() { return preferences } }, + select: useCallback( + (data: UsePreferencesQueryResponse) => { + const isUnderage = (data.userAge || 0) < 18 + if (isUnderage || isAgeRestricted) { + data.moderationPrefs = AGE_RESTRICTED_MODERATION_PREFS + } + return data + }, + [isAgeRestricted], + ), }) } diff --git a/src/storage/schema.ts b/src/storage/schema.ts index 19c31834b..c05a7531d 100644 --- a/src/storage/schema.ts +++ b/src/storage/schema.ts @@ -7,6 +7,7 @@ export type Device = { lastNuxDialog: string | undefined geolocation?: { countryCode: string | undefined + isAgeRestrictedGeo: boolean | undefined } trendingBetaEnabled: boolean devMode: boolean diff --git a/src/view/shell/index.tsx b/src/view/shell/index.tsx index 8c08ec0c0..4d1a8c51b 100644 --- a/src/view/shell/index.tsx +++ b/src/view/shell/index.tsx @@ -25,6 +25,7 @@ import {ModalsContainer} from '#/view/com/modals/Modal' import {ErrorBoundary} from '#/view/com/util/ErrorBoundary' import {atoms as a, select, useTheme} from '#/alf' import {setSystemUITheme} from '#/alf/util/systemUI' +import {AgeAssuranceRedirectDialog} from '#/components/ageAssurance/AgeAssuranceRedirectDialog' import {EmailDialog} from '#/components/dialogs/EmailDialog' import {InAppBrowserConsentDialog} from '#/components/dialogs/InAppBrowserConsent' import {LinkWarningDialog} from '#/components/dialogs/LinkWarning' @@ -155,6 +156,7 @@ function ShellInner() { <MutedWordsDialog /> <SigninDialog /> <EmailDialog /> + <AgeAssuranceRedirectDialog /> <InAppBrowserConsentDialog /> <LinkWarningDialog /> <Lightbox /> diff --git a/src/view/shell/index.web.tsx b/src/view/shell/index.web.tsx index 8969d68f8..77c3f45f6 100644 --- a/src/view/shell/index.web.tsx +++ b/src/view/shell/index.web.tsx @@ -17,6 +17,7 @@ import {Lightbox} from '#/view/com/lightbox/Lightbox' import {ModalsContainer} from '#/view/com/modals/Modal' import {ErrorBoundary} from '#/view/com/util/ErrorBoundary' import {atoms as a, select, useTheme} from '#/alf' +import {AgeAssuranceRedirectDialog} from '#/components/ageAssurance/AgeAssuranceRedirectDialog' import {EmailDialog} from '#/components/dialogs/EmailDialog' import {LinkWarningDialog} from '#/components/dialogs/LinkWarning' import {MutedWordsDialog} from '#/components/dialogs/MutedWords' @@ -70,6 +71,7 @@ function ShellInner() { <MutedWordsDialog /> <SigninDialog /> <EmailDialog /> + <AgeAssuranceRedirectDialog /> <LinkWarningDialog /> <Lightbox /> <PortalOutlet /> |