From 1dbc331314278cb7a42ded9b190dac7038ad9878 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Wed, 16 Jul 2025 13:58:07 -0500 Subject: 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 * 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 --- .eslintrc.js | 1 + bskyweb/cmd/bskyweb/server.go | 1 + package.json | 4 +- src/App.native.tsx | 86 ++--- src/App.web.tsx | 77 ++--- src/components/LanguageSelect.tsx | 50 +++ .../PostControls/ShareMenu/ShareMenuItems.tsx | 4 +- .../PostControls/ShareMenu/ShareMenuItems.web.tsx | 4 +- .../ageAssurance/AgeAssuranceAccountCard.tsx | 148 +++++++++ .../ageAssurance/AgeAssuranceAdmonition.tsx | 100 ++++++ .../ageAssurance/AgeAssuranceAppealDialog.tsx | 140 ++++++++ src/components/ageAssurance/AgeAssuranceBadge.tsx | 46 +++ .../AgeAssuranceDismissibleHeaderButton.tsx | 95 ++++++ .../ageAssurance/AgeAssuranceDismissibleNotice.tsx | 59 ++++ .../ageAssurance/AgeAssuranceInitDialog.tsx | 351 +++++++++++++++++++++ .../ageAssurance/AgeAssuranceRedirectDialog.tsx | 196 ++++++++++++ .../ageAssurance/AgeRestrictedScreen.tsx | 93 ++++++ src/components/ageAssurance/const.ts | 26 ++ src/components/ageAssurance/useAgeAssuranceCopy.ts | 18 ++ src/components/dialogs/Context.tsx | 6 + src/components/moderation/LabelPreference.tsx | 41 +-- src/lib/constants.ts | 3 + src/lib/hooks/useIntentHandler.ts | 38 ++- src/lib/hooks/useTLDs.ts | 15 + src/lib/notifications/notifications.ts | 98 ++++-- src/lib/statsig/gates.ts | 1 + src/logger/types.ts | 1 + src/screens/Messages/ChatList.tsx | 18 +- src/screens/Messages/Conversation.tsx | 17 +- src/screens/Messages/Inbox.tsx | 33 +- src/screens/Messages/Settings.tsx | 18 +- src/screens/Moderation/index.tsx | 59 ++-- src/screens/Settings/AboutSettings.tsx | 16 + src/screens/Settings/AccountSettings.tsx | 22 +- src/screens/Settings/Settings.tsx | 3 + src/state/ageAssurance/const.ts | 11 + src/state/ageAssurance/index.tsx | 140 ++++++++ src/state/ageAssurance/types.ts | 33 ++ src/state/ageAssurance/useAgeAssurance.ts | 45 +++ src/state/ageAssurance/useInitAgeAssurance.ts | 85 +++++ src/state/ageAssurance/useIsAgeAssuranceEnabled.ts | 13 + src/state/geolocation.tsx | 6 +- src/state/queries/nuxs/definitions.ts | 12 + src/state/queries/nuxs/index.ts | 16 +- src/state/queries/post-feed.ts | 27 +- src/state/queries/preferences/index.ts | 15 + src/storage/schema.ts | 1 + src/view/shell/index.tsx | 2 + src/view/shell/index.web.tsx | 2 + yarn.lock | 171 +++++----- 50 files changed, 2204 insertions(+), 263 deletions(-) create mode 100644 src/components/LanguageSelect.tsx create mode 100644 src/components/ageAssurance/AgeAssuranceAccountCard.tsx create mode 100644 src/components/ageAssurance/AgeAssuranceAdmonition.tsx create mode 100644 src/components/ageAssurance/AgeAssuranceAppealDialog.tsx create mode 100644 src/components/ageAssurance/AgeAssuranceBadge.tsx create mode 100644 src/components/ageAssurance/AgeAssuranceDismissibleHeaderButton.tsx create mode 100644 src/components/ageAssurance/AgeAssuranceDismissibleNotice.tsx create mode 100644 src/components/ageAssurance/AgeAssuranceInitDialog.tsx create mode 100644 src/components/ageAssurance/AgeAssuranceRedirectDialog.tsx create mode 100644 src/components/ageAssurance/AgeRestrictedScreen.tsx create mode 100644 src/components/ageAssurance/const.ts create mode 100644 src/components/ageAssurance/useAgeAssuranceCopy.ts create mode 100644 src/lib/hooks/useTLDs.ts create mode 100644 src/state/ageAssurance/const.ts create mode 100644 src/state/ageAssurance/index.tsx create mode 100644 src/state/ageAssurance/types.ts create mode 100644 src/state/ageAssurance/useAgeAssurance.ts create mode 100644 src/state/ageAssurance/useInitAgeAssurance.ts create mode 100644 src/state/ageAssurance/useIsAgeAssuranceEnabled.ts diff --git a/.eslintrc.js b/.eslintrc.js index 726cc5607..59c1b4eb4 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -34,6 +34,7 @@ module.exports = { 'P', 'Admonition', 'Admonition.Admonition', + 'AgeAssuranceAdmonition', 'Span', ], impliedTextProps: [], diff --git a/bskyweb/cmd/bskyweb/server.go b/bskyweb/cmd/bskyweb/server.go index ade7e1065..f3230c0d8 100644 --- a/bskyweb/cmd/bskyweb/server.go +++ b/bskyweb/cmd/bskyweb/server.go @@ -302,6 +302,7 @@ func serve(cctx *cli.Context) error { e.GET("/support/copyright", server.WebGeneric) e.GET("/intent/compose", server.WebGeneric) e.GET("/intent/verify-email", server.WebGeneric) + e.GET("/intent/age-assurance", server.WebGeneric) e.GET("/messages", server.WebGeneric) e.GET("/messages/:conversation", server.WebGeneric) diff --git a/package.json b/package.json index 357879048..cd7c48215 100644 --- a/package.json +++ b/package.json @@ -69,7 +69,7 @@ "icons:optimize": "svgo -f ./assets/icons" }, "dependencies": { - "@atproto/api": "^0.15.21", + "@atproto/api": "^0.15.26", "@bitdrift/react-native": "^0.6.8", "@braintree/sanitize-url": "^6.0.2", "@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet", @@ -218,7 +218,7 @@ "zod": "^3.20.2" }, "devDependencies": { - "@atproto/dev-env": "^0.3.150", + "@atproto/dev-env": "^0.3.155", "@babel/core": "^7.26.0", "@babel/preset-env": "^7.26.0", "@babel/runtime": "^7.26.0", 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}> - - - - {/* LabelDefsProvider MUST come before ModerationOptsProvider */} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + {/* LabelDefsProvider MUST come before ModerationOptsProvider */} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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}> - - - - {/* LabelDefsProvider MUST come before ModerationOptsProvider */} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + {/* LabelDefsProvider MUST come before ModerationOptsProvider */} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 ( + + + + + + ( + + + {label} + + )} + items={items} + /> + + ) +} 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() 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 ( <> - {hasSession && ( + {hasSession && !isAgeRestricted && ( 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 = ({ {!hideInPWI && copyLinkItem} - {hasSession && ( + {hasSession && !isAgeRestricted && ( +} + +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 ( + <> + + + + + + + + + + + + + {copy.notice} + + + {isBlocked ? ( + + + You are currently unable to access Bluesky's Age Assurance flow. + Please{' '} + { + appealControl.open() + })}> + contact our moderation team + {' '} + if you believe this is an error. + + + ) : ( + <> + + + + + {lastInitiatedAt && timeAgo && diff ? ( + + {diff.value === 0 ? ( + Last initiated just now + ) : ( + Last initiated {timeAgo} ago + )} + + ) : ( + + Age assurance only takes a few minutes + + )} + + + )} + + + + ) +} 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 ( + + {children} + + ) +} + +function Inner({ + children, + style, +}: ViewStyleProp & { + children: React.ReactNode + control: Dialog.DialogControlProps +}) { + const t = useTheme() + const {_} = useLingui() + + return ( + <> + + + + + + + {children} + + + Learn more in your{' '} + + account settings. + + + + + + + + ) +} 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 ( + + + + + + + + ) +} + +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 ( + + + + + + + Contact us + + + + + Please provide any additional details you feel moderators may need in + order to properly assess your Age Assurance status. + + + + + { + setDetails(details) + }} + label={_(msg`Additional details (limit 1000 characters)`)} + numberOfLines={4} + onSubmitEditing={() => mutate()} + /> + + + + + + + ) +} 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 ( + + + + Age Assurance + + + ) +} 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 ( + + + + + Age Assurance + + + + ) +} 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 ( + + + {copy.notice} + + + + + ) +} 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 ( + + + + + + + + + ) +} + +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('') + const [languageError, setLanguageError] = useState(false) + const [disabled, setDisabled] = useState(false) + const [language, setLanguage] = useState( + convertToKWSSupportedLanguage(langPrefs.appLanguage), + ) + const [error, setError] = useState('') + + 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 ( + + + + + + {success ? Success! : Verify your age} + + + + {success ? ( + + + Please check your email inbox for further instructions. It may + take a minute or two to arrive. + + + ) : ( + <> + + + We use{' '} + + KWS + {' '} + 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. + + + + This should only take a few minutes. + + + )} + + + {success ? ( + + + + ) : ( + <> + + + + {wasRecentlyInitiated && ( + + + 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. + + + )} + + + + Your email + + + setEmailError('')} + onBlur={() => { + runEmailValidation() + }} + returnKeyType="done" + autoCapitalize="none" + autoComplete="off" + autoCorrect={false} + onSubmitEditing={onSubmit} + /> + + + {emailError ? ( + + {emailError} + + ) : ( + + + Use your account email address, or another real email + address you control, in case KWS or Bluesky needs to + contact you. + + + )} + + + + + Your preferred language + + { + setLanguage(value) + setLanguageError(false) + }} + items={KWS_SUPPORTED_LANGS} + /> + + {languageError && ( + + Please select a language + + )} + + + {error && {error}} + + + + + + + By continuing, you agree to the{' '} + + KWS Terms of Use + {' '} + and acknowledge that KWS will store your verified status with + your hashed email address in accordance with the{' '} + + KWS Privacy Policy + + . 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. + + + + )} + + + ) +} + +// 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 ( + + + + + + + + ) +} + +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 ( + <> + + + + + {error && } + + + {error ? Connection issue : Verifying} + + + {!error && } + + + + {error ? ( + + We were unable to receive the verification due to a connection + issue. It may arrive later. If it does, your account will update + automatically. + + ) : ( + + We're confirming your status with our servers. This dialog should + close in a few seconds. + + )} + + + {error && isNative && ( + + + + )} + + + {error && } + + ) +} 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 ( + + + + + + + + + + ) + } + if (!isAgeRestricted) return children + + return ( + + + + + + {screenTitle ?? Unavailable} + + + + + + + + + + + + + + You must verify your age in order to access this screen. + + + + + {copy.notice} + + + + + + + Go to account settings + + + + + + {infoText && {infoText}} + + + + ) +} 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 } const ControlsContext = createContext(null) @@ -46,6 +48,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) { displayText: string share?: boolean }>() + const ageAssuranceRedirectDialogControl = + useStatefulDialogControl() const ctx = useMemo( () => ({ @@ -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 ( - {!disabled && ( - { - mutate({ - label: identifier, - visibility: values[0] as LabelPreference, - labelerDid: undefined, - }) - }} - ignoreLabel={labelOptions.ignore} - warnLabel={labelOptions.warn} - hideLabel={labelOptions.hide} - /> - )} + { + mutate({ + label: identifier, + visibility: values[0] as LabelPreference, + labelerDid: undefined, + }) + }} + ignoreLabel={labelOptions.ignore} + warnLabel={labelOptions.warn} + hideLabel={labelOptions.hide} + disabled={disabled} + /> ) } 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() + + 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 -export function MessagesScreen({navigation, route}: Props) { + +export function MessagesScreen(props: Props) { + const {_} = useLingui() + const aaCopy = useAgeAssuranceCopy() + + return ( + + + + ) +} + +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 ( + + + + ) +} + +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 -export function MessagesInboxScreen({}: Props) { + +export function MessagesInboxScreen(props: Props) { + const {_} = useLingui() + const aaCopy = useAgeAssuranceCopy() + return ( + + + + ) +} + +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 -export function MessagesSettingsScreen({}: Props) { + +export function MessagesSettingsScreen(props: Props) { + const {_} = useLingui() + const aaCopy = useAgeAssuranceCopy() + + return ( + + + + ) +} + +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 ( - + + + + + Moderation + + + + {isLoading ? ( @@ -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({ Content filters + + + You must complete age assurance in order to access the settings below. + + + - {ageNotSet && ( + {declaredAge === undefined && ( <>