Bluesky app fork with some witchin' additions 💫

Age Assurance V2 (#9479)

* Age Assurance V2

* Tighten up test

* Add todos for sdk migration

* Align RQ versions

* Use useEffect for side effect

* Improve effects, memoize

* Standarize on birthdate

* Copy feedback

* Copilot

* Add support link

* Reove double ..

* Cleanup

* Remove redirect dialog

* Cleanup todos, add comments

* Update splash in main template too

* Mock some stuff

* Exhaustive checks

Co-authored-by: Samuel Newman <mozzius@protonmail.com>

* Exhaustive checks

Co-authored-by: Samuel Newman <mozzius@protonmail.com>

* Small fix to bday handling

* Add comment

* onboarding style tweak

sneaking this in sorry!

* rm unreachable breaks

* Put useIntentHandler back on web

* Remove misleading success set

* Align on birthdate

---------

Co-authored-by: Samuel Newman <mozzius@protonmail.com>

authored by Eric Bailey Samuel Newman and committed by GitHub c4aef9f6 7735183a

Changed files
+3016 -1799
__tests__
bskyweb
templates
src
ageAssurance
components
env
geolocation
lib
logger
screens
Login
Moderation
Onboarding
StepSuggestedAccounts
state
storage
view
screens
Storybook
shell
web
-3
.env.example
··· 36 36 37 37 # bapp-config web worker URL 38 38 BAPP_CONFIG_DEV_URL= 39 - 40 - # Dev-only passthrough value for bapp-config web worker 41 - BAPP_CONFIG_DEV_BYPASS_SECRET=
+4 -4
__tests__/lib/string.test.ts
··· 957 957 }) 958 958 959 959 it('returns the at uri when the input is a valid starterpack at uri', () => { 960 - const validAtUri = 'at://did:123/app.bsky.graph.starterpack/rkey' 960 + const validAtUri = 'at://did:plc:123/app.bsky.graph.starterpack/rkey' 961 961 expect(parseStarterPackUri(validAtUri)).toEqual({ 962 - name: 'did:123', 962 + name: 'did:plc:123', 963 963 rkey: 'rkey', 964 964 }) 965 965 }) 966 966 967 967 it('returns null when the at uri has no rkey', () => { 968 - const validAtUri = 'at://did:123/app.bsky.graph.starterpack' 968 + const validAtUri = 'at://did:plc:123/app.bsky.graph.starterpack' 969 969 expect(parseStarterPackUri(validAtUri)).toEqual(null) 970 970 }) 971 971 972 972 it('returns null when the collection is not app.bsky.graph.starterpack', () => { 973 - const validAtUri = 'at://did:123/app.bsky.graph.list/rkey' 973 + const validAtUri = 'at://did:plc:123/app.bsky.graph.list/rkey' 974 974 expect(parseStarterPackUri(validAtUri)).toEqual(null) 975 975 }) 976 976
+12 -4
bskyweb/templates/base.html
··· 68 68 width: 100%; 69 69 } 70 70 #splash { 71 + display: flex; 71 72 position: fixed; 73 + top: 0; 74 + bottom: 0; 75 + left: 0; 76 + right: 0; 77 + align-items: center; 78 + justify-content: center; 79 + } 80 + #splash svg { 81 + position: relative; 82 + top: -50px; 72 83 width: 100px; 73 - left: 50%; 74 - top: 50%; 75 - transform: translateX(-50%) translateY(-50%) translateY(-50px); 76 84 } 77 85 /** 78 86 * We need these styles to prevent shifting due to scrollbar show/hide on ··· 106 114 <div id="root"> 107 115 <div id="splash"> 108 116 <!-- Bluesky SVG --> 109 - <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 360 320"><path fill="#0085ff" d="M180 142c-16.3-31.7-60.7-90.8-102-120C38.5-5.9 23.4-1 13.5 3.4 2.1 8.6 0 26.2 0 36.5c0 10.4 5.7 84.8 9.4 97.2 12.2 41 55.7 55 95.7 50.5-58.7 8.6-110.8 30-42.4 106.1 75.1 77.9 103-16.7 117.3-64.6 14.3 48 30.8 139 116 64.6 64-64.6 17.6-97.5-41.1-106.1 40 4.4 83.5-9.5 95.7-50.5 3.7-12.4 9.4-86.8 9.4-97.2 0-10.3-2-27.9-13.5-33C336.5-1 321.5-6 282 22c-41.3 29.2-85.7 88.3-102 120Z"/></svg> 117 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 57"><path fill="#006AFF" d="M13.873 3.805C21.21 9.332 29.103 20.537 32 26.55v15.882c0-.338-.13.044-.41.867-1.512 4.456-7.418 21.847-20.923 7.944-7.111-7.32-3.819-14.64 9.125-16.85-7.405 1.264-15.73-.825-18.014-9.015C1.12 23.022 0 8.51 0 6.55 0-3.268 8.579-.182 13.873 3.805ZM50.127 3.805C42.79 9.332 34.897 20.537 32 26.55v15.882c0-.338.13.044.41.867 1.512 4.456 7.418 21.847 20.923 7.944 7.111-7.32 3.819-14.64-9.125-16.85 7.405 1.264 15.73-.825 18.014-9.015C62.88 23.022 64 8.51 64 6.55c0-9.818-8.578-6.732-13.873-2.745Z"/></svg> 110 118 </div> 111 119 </div> 112 120
+2 -2
package.json
··· 72 72 "icons:optimize": "svgo -f ./assets/icons" 73 73 }, 74 74 "dependencies": { 75 - "@atproto/api": "^0.18.0", 75 + "@atproto/api": "^0.18.4", 76 76 "@bitdrift/react-native": "^0.6.8", 77 77 "@braintree/sanitize-url": "^6.0.2", 78 78 "@bsky.app/alf": "^0.1.5", ··· 103 103 "@react-navigation/native-stack": "^7.3.13", 104 104 "@sentry/react-native": "~6.20.0", 105 105 "@tanstack/query-async-storage-persister": "^5.25.0", 106 - "@tanstack/react-query": "^5.8.1", 106 + "@tanstack/react-query": "5.25.0", 107 107 "@tanstack/react-query-persist-client": "^5.25.0", 108 108 "@tiptap/core": "^2.9.1", 109 109 "@tiptap/extension-document": "^2.9.1",
+39 -39
src/App.native.tsx
··· 25 25 import {logger} from '#/logger' 26 26 import {isAndroid, isIOS} from '#/platform/detection' 27 27 import {Provider as A11yProvider} from '#/state/a11y' 28 - import {Provider as AgeAssuranceProvider} from '#/state/ageAssurance' 29 28 import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes' 30 29 import {Provider as DialogStateProvider} from '#/state/dialogs' 31 30 import {Provider as EmailVerificationProvider} from '#/state/email-verification' 32 31 import {listenSessionDropped} from '#/state/events' 33 - import { 34 - beginResolveGeolocationConfig, 35 - ensureGeolocationConfigIsResolved, 36 - Provider as GeolocationProvider, 37 - } from '#/state/geolocation' 38 32 import {GlobalGestureEventsProvider} from '#/state/global-gesture-events' 39 33 import {Provider as HomeBadgeProvider} from '#/state/home-badge' 40 34 import {Provider as LightboxStateProvider} from '#/state/lightbox' ··· 56 50 import {Provider as ShellStateProvider} from '#/state/shell' 57 51 import {Provider as ComposerProvider} from '#/state/shell/composer' 58 52 import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out' 53 + import {Provider as OnboardingProvider} from '#/state/shell/onboarding' 59 54 import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide' 60 55 import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed' 61 56 import {Provider as StarterPackProvider} from '#/state/shell/starter-pack' ··· 73 68 import {Provider as PortalProvider} from '#/components/Portal' 74 69 import {Provider as VideoVolumeProvider} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext' 75 70 import {ToastOutlet} from '#/components/Toast' 71 + import {Provider as AgeAssuranceV2Provider} from '#/ageAssurance' 72 + import {prefetchAgeAssuranceConfig} from '#/ageAssurance' 73 + import * as Geo from '#/geolocation' 76 74 import {Splash} from '#/Splash' 77 75 import {BottomSheetProvider} from '../modules/bottom-sheet' 78 76 import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider' ··· 93 91 /** 94 92 * Begin geolocation ASAP 95 93 */ 96 - beginResolveGeolocationConfig() 94 + Geo.resolve() 95 + prefetchAgeAssuranceConfig() 97 96 98 97 function InnerApp() { 99 98 const [isReady, setIsReady] = React.useState(false) ··· 143 142 <QueryProvider currentDid={currentAccount?.did}> 144 143 <PolicyUpdateOverlayProvider> 145 144 <StatsigProvider> 146 - <AgeAssuranceProvider> 145 + <AgeAssuranceV2Provider> 147 146 <ComposerProvider> 148 147 <MessagesProvider> 149 148 {/* LabelDefsProvider MUST come before ModerationOptsProvider */} ··· 186 185 </LabelDefsProvider> 187 186 </MessagesProvider> 188 187 </ComposerProvider> 189 - </AgeAssuranceProvider> 188 + </AgeAssuranceV2Provider> 190 189 </StatsigProvider> 191 190 </PolicyUpdateOverlayProvider> 192 191 </QueryProvider> ··· 203 202 const [isReady, setReady] = useState(false) 204 203 205 204 React.useEffect(() => { 206 - Promise.all([ 207 - initPersistedState(), 208 - ensureGeolocationConfigIsResolved(), 209 - ]).then(() => setReady(true)) 205 + Promise.all([initPersistedState(), Geo.resolve()]).then(() => 206 + setReady(true), 207 + ) 210 208 }, []) 211 209 212 210 if (!isReady) { ··· 218 216 * that is set up in the InnerApp component above. 219 217 */ 220 218 return ( 221 - <GeolocationProvider> 219 + <Geo.Provider> 222 220 <A11yProvider> 223 221 <KeyboardControllerProvider> 224 - <SessionProvider> 225 - <PrefsStateProvider> 226 - <I18nProvider> 227 - <ShellStateProvider> 228 - <ModalStateProvider> 229 - <DialogStateProvider> 230 - <LightboxStateProvider> 231 - <PortalProvider> 232 - <BottomSheetProvider> 233 - <StarterPackProvider> 234 - <SafeAreaProvider 235 - initialMetrics={initialWindowMetrics}> 236 - <InnerApp /> 237 - </SafeAreaProvider> 238 - </StarterPackProvider> 239 - </BottomSheetProvider> 240 - </PortalProvider> 241 - </LightboxStateProvider> 242 - </DialogStateProvider> 243 - </ModalStateProvider> 244 - </ShellStateProvider> 245 - </I18nProvider> 246 - </PrefsStateProvider> 247 - </SessionProvider> 222 + <OnboardingProvider> 223 + <SessionProvider> 224 + <PrefsStateProvider> 225 + <I18nProvider> 226 + <ShellStateProvider> 227 + <ModalStateProvider> 228 + <DialogStateProvider> 229 + <LightboxStateProvider> 230 + <PortalProvider> 231 + <BottomSheetProvider> 232 + <StarterPackProvider> 233 + <SafeAreaProvider 234 + initialMetrics={initialWindowMetrics}> 235 + <InnerApp /> 236 + </SafeAreaProvider> 237 + </StarterPackProvider> 238 + </BottomSheetProvider> 239 + </PortalProvider> 240 + </LightboxStateProvider> 241 + </DialogStateProvider> 242 + </ModalStateProvider> 243 + </ShellStateProvider> 244 + </I18nProvider> 245 + </PrefsStateProvider> 246 + </SessionProvider> 247 + </OnboardingProvider> 248 248 </KeyboardControllerProvider> 249 249 </A11yProvider> 250 - </GeolocationProvider> 250 + </Geo.Provider> 251 251 ) 252 252 } 253 253
+37 -36
src/App.web.tsx
··· 14 14 import I18nProvider from '#/locale/i18nProvider' 15 15 import {logger} from '#/logger' 16 16 import {Provider as A11yProvider} from '#/state/a11y' 17 - import {Provider as AgeAssuranceProvider} from '#/state/ageAssurance' 18 17 import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes' 19 18 import {Provider as DialogStateProvider} from '#/state/dialogs' 20 19 import {Provider as EmailVerificationProvider} from '#/state/email-verification' 21 20 import {listenSessionDropped} from '#/state/events' 22 - import { 23 - beginResolveGeolocationConfig, 24 - ensureGeolocationConfigIsResolved, 25 - Provider as GeolocationProvider, 26 - } from '#/state/geolocation' 27 21 import {Provider as HomeBadgeProvider} from '#/state/home-badge' 28 22 import {Provider as LightboxStateProvider} from '#/state/lightbox' 29 23 import {MessagesProvider} from '#/state/messages' ··· 44 38 import {Provider as ShellStateProvider} from '#/state/shell' 45 39 import {Provider as ComposerProvider} from '#/state/shell/composer' 46 40 import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out' 41 + import {Provider as OnboardingProvider} from '#/state/shell/onboarding' 47 42 import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide' 48 43 import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed' 49 44 import {Provider as StarterPackProvider} from '#/state/shell/starter-pack' ··· 61 56 import {Provider as ActiveVideoProvider} from '#/components/Post/Embed/VideoEmbed/ActiveVideoWebContext' 62 57 import {Provider as VideoVolumeProvider} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext' 63 58 import {ToastOutlet} from '#/components/Toast' 59 + import {Provider as AgeAssuranceV2Provider} from '#/ageAssurance' 60 + import {prefetchAgeAssuranceConfig} from '#/ageAssurance' 61 + import * as Geo from '#/geolocation' 62 + import {Splash} from '#/Splash' 64 63 import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider' 65 64 import {Provider as HideBottomBarBorderProvider} from './lib/hooks/useHideBottomBarBorder' 66 65 67 66 /** 68 67 * Begin geolocation ASAP 69 68 */ 70 - beginResolveGeolocationConfig() 69 + Geo.resolve() 70 + prefetchAgeAssuranceConfig() 71 71 72 72 function InnerApp() { 73 73 const [isReady, setIsReady] = React.useState(false) ··· 104 104 }, [_]) 105 105 106 106 // wait for session to resume 107 - if (!isReady || !hasCheckedReferrer) return null 107 + if (!isReady || !hasCheckedReferrer) return <Splash isReady /> 108 108 109 109 return ( 110 110 <Alf theme={theme}> ··· 118 118 <QueryProvider currentDid={currentAccount?.did}> 119 119 <PolicyUpdateOverlayProvider> 120 120 <StatsigProvider> 121 - <AgeAssuranceProvider> 121 + <AgeAssuranceV2Provider> 122 122 <ComposerProvider> 123 123 <MessagesProvider> 124 124 {/* LabelDefsProvider MUST come before ModerationOptsProvider */} ··· 157 157 </LabelDefsProvider> 158 158 </MessagesProvider> 159 159 </ComposerProvider> 160 - </AgeAssuranceProvider> 160 + </AgeAssuranceV2Provider> 161 161 </StatsigProvider> 162 162 </PolicyUpdateOverlayProvider> 163 163 </QueryProvider> ··· 174 174 const [isReady, setReady] = useState(false) 175 175 176 176 React.useEffect(() => { 177 - Promise.all([ 178 - initPersistedState(), 179 - ensureGeolocationConfigIsResolved(), 180 - ]).then(() => setReady(true)) 177 + Promise.all([initPersistedState(), Geo.resolve()]).then(() => 178 + setReady(true), 179 + ) 181 180 }, []) 182 181 183 182 if (!isReady) { 184 - return null 183 + return <Splash isReady /> 185 184 } 186 185 187 186 /* ··· 189 188 * that is set up in the InnerApp component above. 190 189 */ 191 190 return ( 192 - <GeolocationProvider> 191 + <Geo.Provider> 193 192 <A11yProvider> 194 - <SessionProvider> 195 - <PrefsStateProvider> 196 - <I18nProvider> 197 - <ShellStateProvider> 198 - <ModalStateProvider> 199 - <DialogStateProvider> 200 - <LightboxStateProvider> 201 - <PortalProvider> 202 - <StarterPackProvider> 203 - <InnerApp /> 204 - </StarterPackProvider> 205 - </PortalProvider> 206 - </LightboxStateProvider> 207 - </DialogStateProvider> 208 - </ModalStateProvider> 209 - </ShellStateProvider> 210 - </I18nProvider> 211 - </PrefsStateProvider> 212 - </SessionProvider> 193 + <OnboardingProvider> 194 + <SessionProvider> 195 + <PrefsStateProvider> 196 + <I18nProvider> 197 + <ShellStateProvider> 198 + <ModalStateProvider> 199 + <DialogStateProvider> 200 + <LightboxStateProvider> 201 + <PortalProvider> 202 + <StarterPackProvider> 203 + <InnerApp /> 204 + </StarterPackProvider> 205 + </PortalProvider> 206 + </LightboxStateProvider> 207 + </DialogStateProvider> 208 + </ModalStateProvider> 209 + </ShellStateProvider> 210 + </I18nProvider> 211 + </PrefsStateProvider> 212 + </SessionProvider> 213 + </OnboardingProvider> 213 214 </A11yProvider> 214 - </GeolocationProvider> 215 + </Geo.Provider> 215 216 ) 216 217 } 217 218
+29
src/Splash.web.tsx
··· 1 + /* 2 + * This is a reimplementation of what exists in our HTML template files 3 + * already. Once the React tree mounts, this is what gets rendered first, until 4 + * the app is ready to go. 5 + */ 6 + 7 + import {View} from 'react-native' 8 + import Svg, {Path} from 'react-native-svg' 9 + 10 + import {atoms as a} from '#/alf' 11 + 12 + const size = 100 13 + const ratio = 57 / 64 14 + 15 + export function Splash() { 16 + return ( 17 + <View style={[a.fixed, a.inset_0, a.align_center, a.justify_center]}> 18 + <Svg 19 + fill="none" 20 + viewBox="0 0 64 57" 21 + style={[a.relative, {width: size, height: size * ratio, top: -50}]}> 22 + <Path 23 + fill="#006AFF" 24 + d="M13.873 3.805C21.21 9.332 29.103 20.537 32 26.55v15.882c0-.338-.13.044-.41.867-1.512 4.456-7.418 21.847-20.923 7.944-7.111-7.32-3.819-14.64 9.125-16.85-7.405 1.264-15.73-.825-18.014-9.015C1.12 23.022 0 8.51 0 6.55 0-3.268 8.579-.182 13.873 3.805ZM50.127 3.805C42.79 9.332 34.897 20.537 32 26.55v15.882c0-.338.13.044.41.867 1.512 4.456 7.418 21.847 20.923 7.944 7.111-7.32 3.819-14.64-9.125-16.85 7.405 1.264 15.73-.825 18.014-9.015C62.88 23.022 64 8.51 64 6.55c0-9.818-8.578-6.732-13.873-2.745Z" 25 + /> 26 + </Svg> 27 + </View> 28 + ) 29 + }
+3
src/ageAssurance/__mocks__/data.tsx
··· 1 + export const prefetchAgeAssuranceData = () => {} 2 + export const setBirthdateForDid = () => {} 3 + export const setCreatedAtForDid = () => {}
+355
src/ageAssurance/components/NoAccessScreen.tsx
··· 1 + import {useCallback, useEffect} from 'react' 2 + import {ScrollView, View} from 'react-native' 3 + import {useSafeAreaInsets} from 'react-native-safe-area-context' 4 + import {msg, Trans} from '@lingui/macro' 5 + import {useLingui} from '@lingui/react' 6 + 7 + import { 8 + SupportCode, 9 + useCreateSupportLink, 10 + } from '#/lib/hooks/useCreateSupportLink' 11 + import {dateDiff, useGetTimeAgo} from '#/lib/hooks/useTimeAgo' 12 + import {logger} from '#/logger' 13 + import {isWeb} from '#/platform/detection' 14 + import {isNative} from '#/platform/detection' 15 + import {useIsBirthdateUpdateAllowed} from '#/state/birthdate' 16 + import {useSessionApi} from '#/state/session' 17 + import {atoms as a, useBreakpoints, useTheme, web} from '#/alf' 18 + import {Admonition} from '#/components/Admonition' 19 + import {AgeAssuranceAppealDialog} from '#/components/ageAssurance/AgeAssuranceAppealDialog' 20 + import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge' 21 + import {AgeAssuranceInitDialog} from '#/components/ageAssurance/AgeAssuranceInitDialog' 22 + import {Button, ButtonIcon, ButtonText} from '#/components/Button' 23 + import {useDialogControl} from '#/components/Dialog' 24 + import * as Dialog from '#/components/Dialog' 25 + import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings' 26 + import {DeviceLocationRequestDialog} from '#/components/dialogs/DeviceLocationRequestDialog' 27 + import {Full as Logo} from '#/components/icons/Logo' 28 + import {ShieldCheck_Stroke2_Corner0_Rounded as ShieldIcon} from '#/components/icons/Shield' 29 + import {createStaticClick, SimpleInlineLinkText} from '#/components/Link' 30 + import {Outlet as PortalOutlet} from '#/components/Portal' 31 + import * as Toast from '#/components/Toast' 32 + import {Text} from '#/components/Typography' 33 + import {BottomSheetOutlet} from '#/../modules/bottom-sheet' 34 + import {useAgeAssurance} from '#/ageAssurance' 35 + import {useAgeAssuranceDataContext} from '#/ageAssurance/data' 36 + import {useComputeAgeAssuranceRegionAccess} from '#/ageAssurance/useComputeAgeAssuranceRegionAccess' 37 + import { 38 + isLegacyBirthdateBug, 39 + useAgeAssuranceRegionConfig, 40 + } from '#/ageAssurance/util' 41 + import {useDeviceGeolocationApi} from '#/geolocation' 42 + 43 + const textStyles = [a.text_md, a.leading_snug] 44 + 45 + export function NoAccessScreen() { 46 + const t = useTheme() 47 + const {_} = useLingui() 48 + const {gtPhone} = useBreakpoints() 49 + const insets = useSafeAreaInsets() 50 + const birthdateControl = useDialogControl() 51 + const {data} = useAgeAssuranceDataContext() 52 + const region = useAgeAssuranceRegionConfig() 53 + const isBirthdateUpdateAllowed = useIsBirthdateUpdateAllowed() 54 + const {logoutCurrentAccount} = useSessionApi() 55 + const createSupportLink = useCreateSupportLink() 56 + 57 + const aa = useAgeAssurance() 58 + const isBlocked = aa.state.status === aa.Status.Blocked 59 + const isAARegion = !!region 60 + const hasDeclaredAge = data?.declaredAge !== undefined 61 + const canUpdateBirthday = 62 + isBirthdateUpdateAllowed || isLegacyBirthdateBug(data?.birthdate || '') 63 + 64 + useEffect(() => { 65 + // just counting overall hits here 66 + logger.metric(`blockedGeoOverlay:shown`, {}) 67 + }, []) 68 + 69 + const onPressLogout = useCallback(() => { 70 + if (isWeb) { 71 + // We're switching accounts, which remounts the entire app. 72 + // On mobile, this gets us Home, but on the web we also need reset the URL. 73 + // We can't change the URL via a navigate() call because the navigator 74 + // itself is about to unmount, and it calls pushState() too late. 75 + // So we change the URL ourselves. The navigator will pick it up on remount. 76 + history.pushState(null, '', '/') 77 + } 78 + logoutCurrentAccount('AgeAssuranceNoAccessScreen') 79 + }, [logoutCurrentAccount]) 80 + 81 + const birthdateUpdateText = canUpdateBirthday ? ( 82 + <Text style={[textStyles]}> 83 + <Trans> 84 + If you believe your birthdate is incorrect, you can update it by{' '} 85 + <SimpleInlineLinkText 86 + label={_(msg`Click here to update your birthdate`)} 87 + style={[textStyles]} 88 + {...createStaticClick(() => { 89 + birthdateControl.open() 90 + })}> 91 + clicking here 92 + </SimpleInlineLinkText> 93 + . 94 + </Trans> 95 + </Text> 96 + ) : ( 97 + <Text style={[textStyles]}> 98 + <Trans> 99 + If you believe your birthdate is incorrect, please{' '} 100 + <SimpleInlineLinkText 101 + to={createSupportLink({code: SupportCode.AA_BIRTHDATE})} 102 + label={_(msg`Click here to contact our support team`)} 103 + style={[textStyles]}> 104 + contact our support team 105 + </SimpleInlineLinkText> 106 + . 107 + </Trans> 108 + </Text> 109 + ) 110 + 111 + return ( 112 + <> 113 + <ScrollView 114 + contentContainerStyle={[ 115 + a.px_2xl, 116 + { 117 + paddingTop: isWeb ? a.p_5xl.padding : insets.top + a.p_2xl.padding, 118 + paddingBottom: 100, 119 + }, 120 + ]}> 121 + <View 122 + style={[ 123 + a.mx_auto, 124 + a.w_full, 125 + web({ 126 + maxWidth: 380, 127 + paddingTop: gtPhone ? '8vh' : undefined, 128 + }), 129 + { 130 + gap: 32, 131 + }, 132 + ]}> 133 + <View style={[a.align_start]}> 134 + <AgeAssuranceBadge /> 135 + </View> 136 + 137 + {hasDeclaredAge ? ( 138 + <> 139 + {isAARegion ? ( 140 + <> 141 + <View style={[a.gap_lg]}> 142 + <Text style={[textStyles]}> 143 + <Trans> 144 + You are accessing Bluesky from a region that legally 145 + requires us to verify your age before allowing you to 146 + access the app. 147 + </Trans> 148 + </Text> 149 + 150 + {!isBlocked && birthdateUpdateText} 151 + </View> 152 + 153 + <AccessSection /> 154 + </> 155 + ) : ( 156 + <View style={[a.gap_lg]}> 157 + <Text style={[textStyles]}> 158 + <Trans> 159 + Unfortunately, the birthdate you have saved to your 160 + profile makes you too young to access Bluesky. 161 + </Trans> 162 + </Text> 163 + 164 + {birthdateUpdateText} 165 + </View> 166 + )} 167 + </> 168 + ) : ( 169 + <View style={[a.gap_lg]}> 170 + <Text style={[textStyles]}> 171 + <Trans> 172 + It looks like you haven't added your birthdate. You must 173 + provide an accurate date of birth to use Bluesky. 174 + </Trans> 175 + </Text> 176 + <Button 177 + color="primary" 178 + size="large" 179 + label={_(msg`Click here to update your birthdate`)} 180 + onPress={() => birthdateControl.open()}> 181 + <ButtonText> 182 + <Trans>Add your birthdate</Trans> 183 + </ButtonText> 184 + </Button> 185 + </View> 186 + )} 187 + 188 + <View style={[a.pt_lg, a.gap_xl]}> 189 + <Logo width={120} textFill={t.atoms.text.color} /> 190 + <Text style={[a.text_sm, a.italic, t.atoms.text_contrast_medium]}> 191 + <Trans> 192 + To log out,{' '} 193 + <SimpleInlineLinkText 194 + label={_(msg`Click here to log out`)} 195 + {...createStaticClick(() => { 196 + onPressLogout() 197 + })}> 198 + click here 199 + </SimpleInlineLinkText> 200 + . 201 + </Trans> 202 + </Text> 203 + </View> 204 + </View> 205 + </ScrollView> 206 + 207 + <BirthDateSettingsDialog control={birthdateControl} /> 208 + 209 + {/* 210 + * While this blocking overlay is up, other dialogs in the shell 211 + * are not mounted, so it _should_ be safe to use these here 212 + * without fear of other modals showing up. 213 + */} 214 + <BottomSheetOutlet /> 215 + <PortalOutlet /> 216 + </> 217 + ) 218 + } 219 + 220 + function AccessSection() { 221 + const t = useTheme() 222 + const {_, i18n} = useLingui() 223 + const control = useDialogControl() 224 + const appealControl = Dialog.useDialogControl() 225 + const locationControl = Dialog.useDialogControl() 226 + const getTimeAgo = useGetTimeAgo() 227 + const {setDeviceGeolocation} = useDeviceGeolocationApi() 228 + const computeAgeAssuranceRegionAccess = useComputeAgeAssuranceRegionAccess() 229 + 230 + const aa = useAgeAssurance() 231 + const {status, lastInitiatedAt} = aa.state 232 + const isBlocked = status === aa.Status.Blocked 233 + const hasInitiated = !!lastInitiatedAt 234 + const timeAgo = lastInitiatedAt 235 + ? getTimeAgo(lastInitiatedAt, new Date()) 236 + : null 237 + const diff = lastInitiatedAt 238 + ? dateDiff(lastInitiatedAt, new Date(), 'down') 239 + : null 240 + 241 + return ( 242 + <> 243 + <AgeAssuranceInitDialog control={control} /> 244 + <AgeAssuranceAppealDialog control={appealControl} /> 245 + 246 + <View style={[a.gap_xl]}> 247 + {isBlocked ? ( 248 + <Admonition type="warning"> 249 + <Trans> 250 + You are currently unable to access Bluesky's Age Assurance flow. 251 + Please{' '} 252 + <SimpleInlineLinkText 253 + label={_(msg`Contact our moderation team`)} 254 + {...createStaticClick(() => { 255 + appealControl.open() 256 + logger.metric('ageAssurance:appealDialogOpen', {}) 257 + })}> 258 + contact our moderation team 259 + </SimpleInlineLinkText>{' '} 260 + if you believe this is an error. 261 + </Trans> 262 + </Admonition> 263 + ) : ( 264 + <> 265 + <View style={[a.gap_md]}> 266 + <Button 267 + label={_(msg`Verify now`)} 268 + size="large" 269 + color={hasInitiated ? 'secondary' : 'primary'} 270 + onPress={() => { 271 + control.open() 272 + logger.metric('ageAssurance:initDialogOpen', { 273 + hasInitiatedPreviously: hasInitiated, 274 + }) 275 + }}> 276 + <ButtonIcon icon={ShieldIcon} /> 277 + <ButtonText> 278 + {hasInitiated ? ( 279 + <Trans>Verify again</Trans> 280 + ) : ( 281 + <Trans>Verify now</Trans> 282 + )} 283 + </ButtonText> 284 + </Button> 285 + 286 + {lastInitiatedAt && timeAgo && diff ? ( 287 + <Text 288 + style={[a.text_sm, a.italic, t.atoms.text_contrast_medium]} 289 + title={i18n.date(lastInitiatedAt, { 290 + dateStyle: 'medium', 291 + timeStyle: 'medium', 292 + })}> 293 + {diff.value === 0 ? ( 294 + <Trans>Last initiated just now</Trans> 295 + ) : ( 296 + <Trans>Last initiated {timeAgo} ago</Trans> 297 + )} 298 + </Text> 299 + ) : ( 300 + <Text 301 + style={[a.text_sm, a.italic, t.atoms.text_contrast_medium]}> 302 + <Trans>Age assurance only takes a few minutes</Trans> 303 + </Text> 304 + )} 305 + </View> 306 + </> 307 + )} 308 + 309 + <View style={[a.gap_xs]}> 310 + {isNative && ( 311 + <> 312 + <Admonition> 313 + <Trans> 314 + Is your location not accurate?{' '} 315 + <SimpleInlineLinkText 316 + label={_(msg`Confirm your location`)} 317 + {...createStaticClick(() => { 318 + locationControl.open() 319 + })}> 320 + Tap here to confirm your location. 321 + </SimpleInlineLinkText>{' '} 322 + </Trans> 323 + </Admonition> 324 + 325 + <DeviceLocationRequestDialog 326 + control={locationControl} 327 + onLocationAcquired={props => { 328 + const access = computeAgeAssuranceRegionAccess( 329 + props.geolocation, 330 + ) 331 + if (access !== aa.Access.Full) { 332 + props.disableDialogAction() 333 + props.setDialogError( 334 + _( 335 + msg`We're sorry, but based on your device's location, you are currently located in a region that requires age assurance.`, 336 + ), 337 + ) 338 + } else { 339 + props.closeDialog(() => { 340 + // set this after close! 341 + setDeviceGeolocation(props.geolocation) 342 + Toast.show(_(msg`Thanks! You're all set.`), { 343 + type: 'success', 344 + }) 345 + }) 346 + } 347 + }} 348 + /> 349 + </> 350 + )} 351 + </View> 352 + </View> 353 + </> 354 + ) 355 + }
+334
src/ageAssurance/components/RedirectOverlay.tsx
··· 1 + import { 2 + createContext, 3 + useCallback, 4 + useContext, 5 + useEffect, 6 + useMemo, 7 + useRef, 8 + useState, 9 + } from 'react' 10 + import {Dimensions, View} from 'react-native' 11 + import * as Linking from 'expo-linking' 12 + import {msg, Trans} from '@lingui/macro' 13 + import {useLingui} from '@lingui/react' 14 + 15 + import {retry} from '#/lib/async/retry' 16 + import {wait} from '#/lib/async/wait' 17 + import {parseLinkingUrl} from '#/lib/parseLinkingUrl' 18 + import {isWeb} from '#/platform/detection' 19 + import {isIOS} from '#/platform/detection' 20 + import {useAgent, useSession} from '#/state/session' 21 + import {atoms as a, platform, useBreakpoints, useTheme} from '#/alf' 22 + import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge' 23 + import {Button, ButtonText} from '#/components/Button' 24 + import {FullWindowOverlay} from '#/components/FullWindowOverlay' 25 + import {CheckThick_Stroke2_Corner0_Rounded as SuccessIcon} from '#/components/icons/Check' 26 + import {CircleInfo_Stroke2_Corner0_Rounded as ErrorIcon} from '#/components/icons/CircleInfo' 27 + import {Loader} from '#/components/Loader' 28 + import {Text} from '#/components/Typography' 29 + import {refetchAgeAssuranceServerState} from '#/ageAssurance' 30 + import {logger} from '#/ageAssurance' 31 + 32 + export type RedirectOverlayState = { 33 + result: 'success' | 'unknown' 34 + actorDid: string 35 + } 36 + 37 + /** 38 + * Validate and parse the query parameters returned from the age assurance 39 + * redirect. If not valid, returns `undefined` and the dialog will not open. 40 + */ 41 + export function parseRedirectOverlayState( 42 + state: { 43 + result?: string 44 + actorDid?: string 45 + } = {}, 46 + ): RedirectOverlayState | undefined { 47 + let result: RedirectOverlayState['result'] = 'unknown' 48 + const actorDid = state.actorDid 49 + 50 + switch (state.result) { 51 + case 'success': 52 + result = 'success' 53 + break 54 + case 'unknown': 55 + default: 56 + result = 'unknown' 57 + break 58 + } 59 + 60 + if (actorDid) { 61 + return { 62 + result, 63 + actorDid, 64 + } 65 + } 66 + } 67 + 68 + const Context = createContext<{ 69 + isOpen: boolean 70 + open: (state: RedirectOverlayState) => void 71 + close: () => void 72 + }>({ 73 + isOpen: false, 74 + open: () => {}, 75 + close: () => {}, 76 + }) 77 + 78 + export function useRedirectOverlayContext() { 79 + return useContext(Context) 80 + } 81 + 82 + export function Provider({children}: {children?: React.ReactNode}) { 83 + const {currentAccount} = useSession() 84 + const incomingUrl = Linking.useLinkingURL() 85 + const [state, setState] = useState<RedirectOverlayState | null>(() => { 86 + if (!incomingUrl) return null 87 + const url = parseLinkingUrl(incomingUrl) 88 + if (url.pathname !== '/intent/age-assurance') return null 89 + const params = url.searchParams 90 + const state = parseRedirectOverlayState({ 91 + result: params.get('result') ?? undefined, 92 + actorDid: params.get('actorDid') ?? undefined, 93 + }) 94 + 95 + if (isWeb) { 96 + // Clear the URL parameters so they don't re-trigger 97 + history.pushState(null, '', '/') 98 + } 99 + 100 + /* 101 + * If we don't have an account or the account doesn't match, do 102 + * nothing. By the time the user switches to their other account, AA 103 + * state should be ready for them. 104 + */ 105 + if (state && currentAccount && state.actorDid === currentAccount.did) { 106 + return state 107 + } 108 + 109 + return null 110 + }) 111 + const open = useCallback((state: RedirectOverlayState) => { 112 + setState(state) 113 + }, []) 114 + const close = useCallback(() => { 115 + setState(null) 116 + }, []) 117 + 118 + return ( 119 + <Context.Provider 120 + value={useMemo( 121 + () => ({ 122 + isOpen: state !== null, 123 + open, 124 + close, 125 + }), 126 + [state, open, close], 127 + )}> 128 + {children} 129 + </Context.Provider> 130 + ) 131 + } 132 + 133 + export function RedirectOverlay() { 134 + const t = useTheme() 135 + const {_} = useLingui() 136 + const {isOpen} = useRedirectOverlayContext() 137 + const {gtMobile} = useBreakpoints() 138 + 139 + return isOpen ? ( 140 + <FullWindowOverlay> 141 + <View 142 + style={[ 143 + a.fixed, 144 + a.inset_0, 145 + // setting a zIndex when using FullWindowOverlay on iOS 146 + // means the taps pass straight through to the underlying content (???) 147 + // so don't set it on iOS. FullWindowOverlay already does the job. 148 + !isIOS && {zIndex: 9999}, 149 + t.atoms.bg, 150 + gtMobile ? a.p_2xl : a.p_xl, 151 + a.align_center, 152 + // @ts-ignore 153 + platform({ 154 + web: { 155 + paddingTop: '35vh', 156 + }, 157 + default: { 158 + paddingTop: Dimensions.get('window').height * 0.35, 159 + }, 160 + }), 161 + ]}> 162 + <View 163 + role="dialog" 164 + aria-role="dialog" 165 + aria-label={_(msg`Verifying your age assurance status`)}> 166 + <View style={[a.pb_3xl, {width: 300}]}> 167 + <Inner /> 168 + </View> 169 + </View> 170 + </View> 171 + </FullWindowOverlay> 172 + ) : null 173 + } 174 + 175 + function Inner() { 176 + const t = useTheme() 177 + const {_} = useLingui() 178 + const agent = useAgent() 179 + const polling = useRef(false) 180 + const unmounted = useRef(false) 181 + const [error, setError] = useState(false) 182 + const [success, setSuccess] = useState(false) 183 + const {close} = useRedirectOverlayContext() 184 + 185 + useEffect(() => { 186 + if (polling.current) return 187 + 188 + polling.current = true 189 + 190 + logger.metric('ageAssurance:redirectDialogOpen', {}) 191 + 192 + wait( 193 + 3e3, 194 + retry( 195 + 5, 196 + () => true, 197 + async () => { 198 + if (!agent.session) return 199 + if (unmounted.current) return 200 + 201 + const data = await refetchAgeAssuranceServerState({agent}) 202 + 203 + if (data?.state.status !== 'assured') { 204 + throw new Error( 205 + `Polling for age assurance state did not receive assured status`, 206 + ) 207 + } 208 + 209 + return data 210 + }, 211 + 1e3, 212 + ), 213 + ) 214 + .then(async data => { 215 + if (!data) return 216 + if (!agent.session) return 217 + if (unmounted.current) return 218 + 219 + setSuccess(true) 220 + 221 + logger.metric('ageAssurance:redirectDialogSuccess', {}) 222 + }) 223 + .catch(() => { 224 + if (unmounted.current) return 225 + setError(true) 226 + logger.metric('ageAssurance:redirectDialogFail', {}) 227 + }) 228 + 229 + return () => { 230 + unmounted.current = true 231 + } 232 + }, [agent]) 233 + 234 + if (success) { 235 + return ( 236 + <> 237 + <View style={[a.align_start, a.w_full]}> 238 + <AgeAssuranceBadge /> 239 + 240 + <View 241 + style={[ 242 + a.flex_row, 243 + a.justify_between, 244 + a.align_center, 245 + a.gap_sm, 246 + a.pt_lg, 247 + a.pb_md, 248 + ]}> 249 + <SuccessIcon size="sm" fill={t.palette.positive_500} /> 250 + <Text style={[a.text_3xl, a.font_bold]}> 251 + <Trans>Success</Trans> 252 + </Text> 253 + </View> 254 + 255 + <Text style={[a.text_md, a.leading_snug]}> 256 + <Trans> 257 + We've confirmed your age assurance status. You can now close this 258 + dialog. 259 + </Trans> 260 + </Text> 261 + 262 + <View style={[a.w_full, a.pt_lg]}> 263 + <Button 264 + label={_(msg`Close`)} 265 + size="large" 266 + variant="solid" 267 + color="secondary" 268 + onPress={() => close()}> 269 + <ButtonText> 270 + <Trans>Close</Trans> 271 + </ButtonText> 272 + </Button> 273 + </View> 274 + </View> 275 + </> 276 + ) 277 + } 278 + 279 + return ( 280 + <> 281 + <View style={[a.align_start, a.w_full]}> 282 + <AgeAssuranceBadge /> 283 + 284 + <View 285 + style={[ 286 + a.flex_row, 287 + a.justify_between, 288 + a.align_center, 289 + a.gap_sm, 290 + a.pt_lg, 291 + a.pb_md, 292 + ]}> 293 + {error && <ErrorIcon size="lg" fill={t.palette.negative_500} />} 294 + 295 + <Text style={[a.text_3xl, a.font_bold]}> 296 + {error ? <Trans>Connection issue</Trans> : <Trans>Verifying</Trans>} 297 + </Text> 298 + 299 + {!error && <Loader size="lg" />} 300 + </View> 301 + 302 + <Text style={[a.text_md, t.atoms.text_contrast_medium, a.leading_snug]}> 303 + {error ? ( 304 + <Trans> 305 + We were unable to receive the verification due to a connection 306 + issue. It may arrive later. If it does, your account will update 307 + automatically. 308 + </Trans> 309 + ) : ( 310 + <Trans> 311 + We're confirming your age assurance status with our servers. This 312 + should only take a few seconds. 313 + </Trans> 314 + )} 315 + </Text> 316 + 317 + {error && ( 318 + <View style={[a.w_full, a.pt_lg]}> 319 + <Button 320 + label={_(msg`Close`)} 321 + size="large" 322 + variant="solid" 323 + color="secondary" 324 + onPress={() => close()}> 325 + <ButtonText> 326 + <Trans>Close</Trans> 327 + </ButtonText> 328 + </Button> 329 + </View> 330 + )} 331 + </View> 332 + </> 333 + ) 334 + }
+495
src/ageAssurance/data.tsx
··· 1 + import {createContext, useCallback, useContext, useEffect, useMemo} from 'react' 2 + import { 3 + type AppBskyAgeassuranceDefs, 4 + type AppBskyAgeassuranceGetConfig, 5 + type AppBskyAgeassuranceGetState, 6 + AtpAgent, 7 + getAgeAssuranceRegionConfig, 8 + } from '@atproto/api' 9 + import AsyncStorage from '@react-native-async-storage/async-storage' 10 + import {createAsyncStoragePersister} from '@tanstack/query-async-storage-persister' 11 + import {focusManager, QueryClient, useQuery} from '@tanstack/react-query' 12 + import {persistQueryClient} from '@tanstack/react-query-persist-client' 13 + import debounce from 'lodash.debounce' 14 + 15 + import {networkRetry} from '#/lib/async/retry' 16 + import {PUBLIC_BSKY_SERVICE} from '#/lib/constants' 17 + import {getAge} from '#/lib/strings/time' 18 + import {snoozeBirthdateUpdateAllowedForDid} from '#/state/birthdate' 19 + import {useAgent, useSession} from '#/state/session' 20 + import * as debug from '#/ageAssurance/debug' 21 + import {logger} from '#/ageAssurance/logger' 22 + import {isLegacyBirthdateBug} from '#/ageAssurance/util' 23 + import {IS_DEV} from '#/env' 24 + import {device} from '#/storage' 25 + 26 + /** 27 + * Special query client for age assurance data so we can prefetch on app 28 + * load without interfering with other queries. 29 + */ 30 + const qc = new QueryClient({ 31 + defaultOptions: { 32 + queries: { 33 + /** 34 + * We clear this manually, so disable automatic garbage collection. 35 + * @see https://tanstack.com/query/latest/docs/framework/react/plugins/persistQueryClient#how-it-works 36 + */ 37 + gcTime: Infinity, 38 + }, 39 + }, 40 + }) 41 + const persister = createAsyncStoragePersister({ 42 + storage: AsyncStorage, 43 + key: 'age-assurance-query-client', 44 + }) 45 + const [, cacheHydrationPromise] = persistQueryClient({ 46 + queryClient: qc, 47 + persister, 48 + }) 49 + 50 + function getDidFromAgentSession(agent: AtpAgent) { 51 + const sessionManager = agent.sessionManager 52 + if (!sessionManager || !sessionManager.did) return 53 + return sessionManager.did 54 + } 55 + 56 + /* 57 + * Optimistic data 58 + */ 59 + 60 + const createdAtCache = new Map<string, string>() 61 + export function setCreatedAtForDid({ 62 + did, 63 + createdAt, 64 + }: { 65 + did: string 66 + createdAt: string 67 + }) { 68 + createdAtCache.set(did, createdAt) 69 + } 70 + const birthdateCache = new Map<string, string>() 71 + export function setBirthdateForDid({ 72 + did, 73 + birthdate, 74 + }: { 75 + did: string 76 + birthdate: string 77 + }) { 78 + birthdateCache.set(did, birthdate) 79 + } 80 + 81 + /* 82 + * Config 83 + */ 84 + 85 + export const configQueryKey = ['config'] 86 + export async function getConfig() { 87 + if (debug.enabled) return debug.resolve(debug.config) 88 + const agent = new AtpAgent({ 89 + service: PUBLIC_BSKY_SERVICE, 90 + }) 91 + const res = await agent.app.bsky.ageassurance.getConfig() 92 + return res.data 93 + } 94 + export function getConfigFromCache(): 95 + | AppBskyAgeassuranceGetConfig.OutputSchema 96 + | undefined { 97 + return qc.getQueryData<AppBskyAgeassuranceGetConfig.OutputSchema>( 98 + configQueryKey, 99 + ) 100 + } 101 + let configPrefetchPromise: Promise<void> | undefined 102 + export async function prefetchConfig() { 103 + if (configPrefetchPromise) { 104 + logger.debug(`prefetchAgeAssuranceConfig: already in progress`) 105 + return 106 + } 107 + 108 + configPrefetchPromise = new Promise(async resolve => { 109 + await cacheHydrationPromise 110 + const cached = getConfigFromCache() 111 + 112 + if (cached) { 113 + logger.debug(`prefetchAgeAssuranceConfig: using cache`) 114 + resolve() 115 + } else { 116 + try { 117 + logger.debug(`prefetchAgeAssuranceConfig: resolving...`) 118 + const res = await networkRetry(3, () => getConfig()) 119 + qc.setQueryData<AppBskyAgeassuranceGetConfig.OutputSchema>( 120 + configQueryKey, 121 + res, 122 + ) 123 + } catch (e: any) { 124 + logger.warn(`prefetchAgeAssuranceConfig: failed`, { 125 + safeMessage: e.message, 126 + }) 127 + } finally { 128 + resolve() 129 + } 130 + } 131 + }) 132 + } 133 + export function useConfigQuery() { 134 + return useQuery( 135 + { 136 + /** 137 + * Will re-fetch when stale, at most every hour (or 5s in dev for easier 138 + * testing). 139 + * 140 + * @see https://tanstack.com/query/latest/docs/framework/react/guides/initial-query-data#initial-data-from-the-cache-with-initialdataupdatedat 141 + */ 142 + staleTime: IS_DEV ? 5e3 : 1000 * 60 * 60, 143 + initialData: getConfigFromCache(), 144 + initialDataUpdatedAt: () => 145 + qc.getQueryState(configQueryKey)?.dataUpdatedAt, 146 + queryKey: configQueryKey, 147 + async queryFn() { 148 + logger.debug(`useConfigQuery: fetching config`) 149 + return getConfig() 150 + }, 151 + }, 152 + qc, 153 + ) 154 + } 155 + 156 + /* 157 + * Server state 158 + */ 159 + 160 + export function createServerStateQueryKey({did}: {did: string}) { 161 + return ['serverState', did] 162 + } 163 + export async function getServerState({agent}: {agent: AtpAgent}) { 164 + if (debug.enabled && debug.serverState) 165 + return debug.resolve(debug.serverState) 166 + const geolocation = device.get(['mergedGeolocation']) 167 + if (!geolocation || !geolocation.countryCode) { 168 + logger.error(`getServerState: missing geolocation countryCode`) 169 + return 170 + } 171 + const {data} = await agent.app.bsky.ageassurance.getState({ 172 + countryCode: geolocation.countryCode, 173 + regionCode: geolocation.regionCode, 174 + }) 175 + const did = getDidFromAgentSession(agent) 176 + if (data && did && createdAtCache.has(did)) { 177 + /* 178 + * If account was just created, just use the local cache if available. On 179 + * subsequent reloads, the server should have the correct value. 180 + */ 181 + data.metadata.accountCreatedAt = createdAtCache.get(did) 182 + } 183 + return data ?? null 184 + } 185 + export function getServerStateFromCache({ 186 + did, 187 + }: { 188 + did: string 189 + }): AppBskyAgeassuranceGetState.OutputSchema | undefined { 190 + return qc.getQueryData<AppBskyAgeassuranceGetState.OutputSchema>( 191 + createServerStateQueryKey({did}), 192 + ) 193 + } 194 + export async function prefetchServerState({agent}: {agent: AtpAgent}) { 195 + const did = getDidFromAgentSession(agent) 196 + 197 + if (!did) return 198 + 199 + await cacheHydrationPromise 200 + const qk = createServerStateQueryKey({did}) 201 + const cached = getServerStateFromCache({did}) 202 + 203 + if (cached) { 204 + logger.debug(`prefetchServerState: using cache`) 205 + return 206 + } 207 + 208 + try { 209 + logger.debug(`prefetchServerState: resolving...`) 210 + const res = await networkRetry(3, () => getServerState({agent})) 211 + qc.setQueryData<AppBskyAgeassuranceGetState.OutputSchema>(qk, res) 212 + } catch (e: any) { 213 + logger.warn(`prefetchServerState: failed`, { 214 + safeMessage: e.message, 215 + }) 216 + } 217 + } 218 + export async function refetchServerState({agent}: {agent: AtpAgent}) { 219 + const did = getDidFromAgentSession(agent) 220 + if (!did) return 221 + logger.debug(`refetchServerState: fetching...`) 222 + const res = await networkRetry(3, () => getServerState({agent})) 223 + qc.setQueryData<AppBskyAgeassuranceGetState.OutputSchema>( 224 + createServerStateQueryKey({did}), 225 + res, 226 + ) 227 + return res 228 + } 229 + export function usePatchServerState() { 230 + const {currentAccount} = useSession() 231 + return useCallback( 232 + async (next: AppBskyAgeassuranceDefs.State) => { 233 + if (!currentAccount) return 234 + const did = currentAccount.did 235 + const prev = getServerStateFromCache({did}) 236 + const merged: AppBskyAgeassuranceGetState.OutputSchema = { 237 + metadata: {}, 238 + ...(prev || {}), 239 + state: next, 240 + } 241 + qc.setQueryData<AppBskyAgeassuranceGetState.OutputSchema>( 242 + createServerStateQueryKey({did}), 243 + merged, 244 + ) 245 + }, 246 + [currentAccount], 247 + ) 248 + } 249 + export function useServerStateQuery() { 250 + const agent = useAgent() 251 + const did = getDidFromAgentSession(agent) 252 + const query = useQuery( 253 + { 254 + enabled: !!did, 255 + initialData: () => { 256 + if (!did) return 257 + return getServerStateFromCache({did}) 258 + }, 259 + queryKey: createServerStateQueryKey({did: did!}), 260 + async queryFn() { 261 + return getServerState({agent}) 262 + }, 263 + }, 264 + qc, 265 + ) 266 + const refetch = useMemo(() => debounce(query.refetch, 100), [query.refetch]) 267 + 268 + const isAssured = query.data?.state?.status === 'assured' 269 + 270 + /** 271 + * `refetchOnWindowFocus` doesn't seem to want to work for this custom query 272 + * client, so we manually subscribe to focus changes. 273 + */ 274 + useEffect(() => { 275 + return focusManager.subscribe(() => { 276 + // logged out 277 + if (!did) return 278 + 279 + const isFocused = focusManager.isFocused() 280 + 281 + if (!isFocused) return 282 + 283 + const config = getConfigFromCache() 284 + const geolocation = device.get(['mergedGeolocation']) 285 + const isAArequired = Boolean( 286 + config && 287 + geolocation && 288 + !!getAgeAssuranceRegionConfig(config, { 289 + countryCode: geolocation?.countryCode ?? '', 290 + regionCode: geolocation?.regionCode, 291 + }), 292 + ) 293 + 294 + // only refetch when needed 295 + if (isAssured || !isAArequired) return 296 + 297 + refetch() 298 + }) 299 + }, [did, refetch, isAssured]) 300 + 301 + return query 302 + } 303 + 304 + /* 305 + * Other required data 306 + */ 307 + 308 + export type OtherRequiredData = { 309 + birthdate: string | undefined 310 + } 311 + export function createOtherRequiredDataQueryKey({did}: {did: string}) { 312 + return ['otherRequiredData', did] 313 + } 314 + export async function getOtherRequiredData({ 315 + agent, 316 + }: { 317 + agent: AtpAgent 318 + }): Promise<OtherRequiredData> { 319 + if (debug.enabled) return debug.resolve(debug.otherRequiredData) 320 + const [prefs] = await Promise.all([agent.getPreferences()]) 321 + const data: OtherRequiredData = { 322 + birthdate: prefs.birthDate ? prefs.birthDate.toISOString() : undefined, 323 + } 324 + const did = getDidFromAgentSession(agent) 325 + if (data && did && birthdateCache.has(did)) { 326 + /* 327 + * If birthdate was just set, use the local cache value. On subsequent 328 + * reloads, the server should have the correct value. 329 + */ 330 + data.birthdate = birthdateCache.get(did) 331 + } 332 + 333 + /** 334 + * If the user is under the minimum age, and the birthdate is not due to 335 + * the legacy bug, snooze further birthdate updates for this user. 336 + */ 337 + if (data.birthdate && !isLegacyBirthdateBug(data.birthdate)) { 338 + snoozeBirthdateUpdateAllowedForDid(did!) 339 + } 340 + 341 + return data 342 + } 343 + export function getOtherRequiredDataFromCache({ 344 + did, 345 + }: { 346 + did: string 347 + }): OtherRequiredData | undefined { 348 + return qc.getQueryData<OtherRequiredData>( 349 + createOtherRequiredDataQueryKey({did}), 350 + ) 351 + } 352 + export async function prefetchOtherRequiredData({agent}: {agent: AtpAgent}) { 353 + const did = getDidFromAgentSession(agent) 354 + 355 + if (!did) return 356 + 357 + await cacheHydrationPromise 358 + const qk = createOtherRequiredDataQueryKey({did}) 359 + const cached = getOtherRequiredDataFromCache({did}) 360 + 361 + if (cached) { 362 + logger.debug(`prefetchOtherRequiredData: using cache`) 363 + return 364 + } 365 + 366 + try { 367 + logger.debug(`prefetchOtherRequiredData: resolving...`) 368 + const res = await networkRetry(3, () => getOtherRequiredData({agent})) 369 + qc.setQueryData<OtherRequiredData>(qk, res) 370 + } catch (e: any) { 371 + logger.warn(`prefetchOtherRequiredData: failed`, { 372 + safeMessage: e.message, 373 + }) 374 + } 375 + } 376 + export function usePatchOtherRequiredData() { 377 + const {currentAccount} = useSession() 378 + return useCallback( 379 + async (next: OtherRequiredData) => { 380 + if (!currentAccount) return 381 + const did = currentAccount.did 382 + const prev = getOtherRequiredDataFromCache({did}) 383 + const merged: OtherRequiredData = { 384 + ...(prev || {}), 385 + ...next, 386 + } 387 + qc.setQueryData<OtherRequiredData>( 388 + createOtherRequiredDataQueryKey({did}), 389 + merged, 390 + ) 391 + }, 392 + [currentAccount], 393 + ) 394 + } 395 + export function useOtherRequiredDataQuery() { 396 + const agent = useAgent() 397 + const did = getDidFromAgentSession(agent) 398 + return useQuery( 399 + { 400 + enabled: !!did, 401 + initialData: () => { 402 + if (!did) return 403 + return getOtherRequiredDataFromCache({did}) 404 + }, 405 + queryKey: createOtherRequiredDataQueryKey({did: did!}), 406 + async queryFn() { 407 + return getOtherRequiredData({agent}) 408 + }, 409 + }, 410 + qc, 411 + ) 412 + } 413 + 414 + /** 415 + * Helper to prefetch all age assurance data. 416 + */ 417 + export function prefetchAgeAssuranceData({agent}: {agent: AtpAgent}) { 418 + return Promise.allSettled([ 419 + // config fetch initiated at the top of the App.platform.tsx files, awaited here 420 + configPrefetchPromise, 421 + prefetchServerState({agent}), 422 + prefetchOtherRequiredData({agent}), 423 + ]) 424 + } 425 + 426 + export function clearAgeAssuranceDataForDid({did}: {did: string}) { 427 + logger.debug(`clearAgeAssuranceDataForDid: ${did}`) 428 + qc.removeQueries({queryKey: createServerStateQueryKey({did}), exact: true}) 429 + qc.removeQueries({ 430 + queryKey: createOtherRequiredDataQueryKey({did}), 431 + exact: true, 432 + }) 433 + } 434 + 435 + export function clearAgeAssuranceData() { 436 + logger.debug(`clearAgeAssuranceData`) 437 + qc.clear() 438 + } 439 + 440 + /* 441 + * Context 442 + */ 443 + 444 + export type AgeAssuranceData = { 445 + config: AppBskyAgeassuranceDefs.Config | undefined 446 + state: AppBskyAgeassuranceDefs.State | undefined 447 + data: 448 + | { 449 + accountCreatedAt: AppBskyAgeassuranceDefs.StateMetadata['accountCreatedAt'] 450 + declaredAge: number | undefined 451 + birthdate: string | undefined 452 + } 453 + | undefined 454 + } 455 + export const AgeAssuranceDataContext = createContext<AgeAssuranceData>({ 456 + config: undefined, 457 + state: undefined, 458 + data: { 459 + accountCreatedAt: undefined, 460 + declaredAge: undefined, 461 + birthdate: undefined, 462 + }, 463 + }) 464 + export function useAgeAssuranceDataContext() { 465 + return useContext(AgeAssuranceDataContext) 466 + } 467 + export function AgeAssuranceDataProvider({ 468 + children, 469 + }: { 470 + children: React.ReactNode 471 + }) { 472 + const {data: config} = useConfigQuery() 473 + const serverState = useServerStateQuery() 474 + const {state, metadata} = serverState.data || {} 475 + const {data} = useOtherRequiredDataQuery() 476 + const ctx = useMemo( 477 + () => ({ 478 + config, 479 + state, 480 + data: { 481 + accountCreatedAt: metadata?.accountCreatedAt, 482 + declaredAge: data?.birthdate 483 + ? getAge(new Date(data.birthdate)) 484 + : undefined, 485 + birthdate: data?.birthdate, 486 + }, 487 + }), 488 + [config, state, data, metadata], 489 + ) 490 + return ( 491 + <AgeAssuranceDataContext.Provider value={ctx}> 492 + {children} 493 + </AgeAssuranceDataContext.Provider> 494 + ) 495 + }
+84
src/ageAssurance/debug.ts
··· 1 + import { 2 + ageAssuranceRuleIDs as ids, 3 + type AppBskyAgeassuranceDefs, 4 + type AppBskyAgeassuranceGetState, 5 + } from '@atproto/api' 6 + 7 + import {type OtherRequiredData} from '#/ageAssurance/data' 8 + import {IS_DEV} from '#/env' 9 + import {type Geolocation} from '#/geolocation' 10 + 11 + export const enabled = IS_DEV && false 12 + 13 + export const geolocation: Geolocation | undefined = enabled 14 + ? { 15 + countryCode: 'AA', 16 + regionCode: undefined, 17 + } 18 + : undefined 19 + 20 + export const deviceGeolocation: Geolocation | undefined = enabled 21 + ? { 22 + countryCode: 'AA', 23 + regionCode: undefined, 24 + } 25 + : undefined 26 + 27 + export const config: AppBskyAgeassuranceDefs.Config = { 28 + regions: [ 29 + { 30 + countryCode: 'AA', 31 + regionCode: undefined, 32 + rules: [ 33 + { 34 + $type: ids.IfAccountNewerThan, 35 + date: '2025-12-01T00:00:00Z', 36 + access: 'none', 37 + }, 38 + { 39 + $type: ids.IfAssuredOverAge, 40 + age: 18, 41 + access: 'full', 42 + }, 43 + { 44 + $type: ids.IfAssuredOverAge, 45 + age: 16, 46 + access: 'safe', 47 + }, 48 + { 49 + $type: ids.IfDeclaredUnderAge, 50 + age: 16, 51 + access: 'none', 52 + }, 53 + { 54 + $type: ids.Default, 55 + access: 'safe', 56 + }, 57 + ], 58 + }, 59 + ], 60 + } 61 + 62 + export const otherRequiredData: OtherRequiredData = { 63 + birthdate: new Date(2000, 1, 1).toISOString(), 64 + } 65 + 66 + const serverStateEnabled = false 67 + export const serverState: AppBskyAgeassuranceGetState.OutputSchema | undefined = 68 + serverStateEnabled 69 + ? { 70 + state: { 71 + lastInitiatedAt: new Date(2023, 5, 1).toISOString(), 72 + status: 'assured', 73 + access: 'safe', 74 + }, 75 + metadata: { 76 + accountCreatedAt: new Date(2023, 11, 1).toISOString(), 77 + }, 78 + } 79 + : undefined 80 + 81 + export async function resolve<T>(data: T) { 82 + await new Promise(y => setTimeout(y, 2000)) // simulate network 83 + return data 84 + }
+90
src/ageAssurance/index.tsx
··· 1 + import {createContext, useCallback, useContext, useEffect, useMemo} from 'react' 2 + 3 + import {useGetAndRegisterPushToken} from '#/lib/notifications/notifications' 4 + import {Provider as RedirectOverlayProvider} from '#/ageAssurance/components/RedirectOverlay' 5 + import {AgeAssuranceDataProvider} from '#/ageAssurance/data' 6 + import {logger} from '#/ageAssurance/logger' 7 + import { 8 + useAgeAssuranceState, 9 + useOnAgeAssuranceAccessUpdate, 10 + } from '#/ageAssurance/state' 11 + import { 12 + AgeAssuranceAccess, 13 + type AgeAssuranceState, 14 + AgeAssuranceStatus, 15 + } from '#/ageAssurance/types' 16 + 17 + export { 18 + prefetchConfig as prefetchAgeAssuranceConfig, 19 + prefetchAgeAssuranceData, 20 + refetchServerState as refetchAgeAssuranceServerState, 21 + usePatchOtherRequiredData as usePatchAgeAssuranceOtherRequiredData, 22 + usePatchServerState as usePatchAgeAssuranceServerState, 23 + } from '#/ageAssurance/data' 24 + export {logger} from '#/ageAssurance/logger' 25 + 26 + const AgeAssuranceStateContext = createContext<{ 27 + Access: typeof AgeAssuranceAccess 28 + Status: typeof AgeAssuranceStatus 29 + state: AgeAssuranceState 30 + }>({ 31 + Access: AgeAssuranceAccess, 32 + Status: AgeAssuranceStatus, 33 + state: { 34 + lastInitiatedAt: undefined, 35 + status: AgeAssuranceStatus.Unknown, 36 + access: AgeAssuranceAccess.Full, 37 + }, 38 + }) 39 + 40 + /** 41 + * THE MAIN AGE ASSURANCE CONTEXT HOOK 42 + * 43 + * Prefer this to using any of the lower-level data-provider hooks. 44 + */ 45 + export function useAgeAssurance() { 46 + return useContext(AgeAssuranceStateContext) 47 + } 48 + 49 + export function Provider({children}: {children: React.ReactNode}) { 50 + return ( 51 + <AgeAssuranceDataProvider> 52 + <InnerProvider> 53 + <RedirectOverlayProvider>{children}</RedirectOverlayProvider> 54 + </InnerProvider> 55 + </AgeAssuranceDataProvider> 56 + ) 57 + } 58 + 59 + function InnerProvider({children}: {children: React.ReactNode}) { 60 + const state = useAgeAssuranceState() 61 + const getAndRegisterPushToken = useGetAndRegisterPushToken() 62 + 63 + const handleAccessUpdate = useCallback( 64 + (s: AgeAssuranceState) => { 65 + getAndRegisterPushToken({ 66 + isAgeRestricted: s.access !== AgeAssuranceAccess.Full, 67 + }) 68 + }, 69 + [getAndRegisterPushToken], 70 + ) 71 + useOnAgeAssuranceAccessUpdate(handleAccessUpdate) 72 + 73 + useEffect(() => { 74 + logger.debug(`useAgeAssuranceState`, {state}) 75 + }, [state]) 76 + 77 + return ( 78 + <AgeAssuranceStateContext.Provider 79 + value={useMemo( 80 + () => ({ 81 + Access: AgeAssuranceAccess, 82 + Status: AgeAssuranceStatus, 83 + state, 84 + }), 85 + [state], 86 + )}> 87 + {children} 88 + </AgeAssuranceStateContext.Provider> 89 + ) 90 + }
+100
src/ageAssurance/state.ts
··· 1 + import {useEffect, useMemo, useState} from 'react' 2 + import {computeAgeAssuranceRegionAccess} from '@atproto/api' 3 + 4 + import {useSession} from '#/state/session' 5 + import {useAgeAssuranceDataContext} from '#/ageAssurance/data' 6 + import {logger} from '#/ageAssurance/logger' 7 + import { 8 + AgeAssuranceAccess, 9 + type AgeAssuranceState, 10 + AgeAssuranceStatus, 11 + parseAccessFromString, 12 + parseStatusFromString, 13 + } from '#/ageAssurance/types' 14 + import {getAgeAssuranceRegionConfigWithFallback} from '#/ageAssurance/util' 15 + import {useGeolocation} from '#/geolocation' 16 + 17 + export function useAgeAssuranceState(): AgeAssuranceState { 18 + const {hasSession} = useSession() 19 + const geolocation = useGeolocation() 20 + const {config, state, data} = useAgeAssuranceDataContext() 21 + 22 + return useMemo(() => { 23 + /** 24 + * This is where we control logged-out moderation prefs. It's all 25 + * downstream of AA now. 26 + */ 27 + if (!hasSession) 28 + return { 29 + status: AgeAssuranceStatus.Unknown, 30 + access: AgeAssuranceAccess.Safe, 31 + } 32 + 33 + // should never happen, but need to guard 34 + if (!config) { 35 + logger.warn('useAgeAssuranceState: missing config') 36 + return { 37 + status: AgeAssuranceStatus.Unknown, 38 + access: AgeAssuranceAccess.Unknown, 39 + } 40 + } 41 + 42 + const region = getAgeAssuranceRegionConfigWithFallback(config, geolocation) 43 + const isAARequired = region.countryCode !== '*' 44 + const isTerminalState = 45 + state?.status === 'assured' || state?.status === 'blocked' 46 + 47 + /* 48 + * If we are in a terminal state and AA is required for this region, 49 + * we can trust the server state completely and avoid recomputing. 50 + */ 51 + if (isTerminalState && isAARequired) { 52 + return { 53 + lastInitiatedAt: state.lastInitiatedAt, 54 + status: parseStatusFromString(state.status), 55 + access: parseAccessFromString(state.access), 56 + } 57 + } 58 + 59 + /* 60 + * Otherwise, we need to compute the access based on the latest data. For 61 + * accounts with an accurate birthdate, our default fallback rules should 62 + * ensure correct access. 63 + */ 64 + const result = computeAgeAssuranceRegionAccess(region, data) 65 + const computed = { 66 + lastInitiatedAt: state?.lastInitiatedAt, 67 + // prefer server state 68 + status: state?.status 69 + ? parseStatusFromString(state?.status) 70 + : AgeAssuranceStatus.Unknown, 71 + // prefer server state 72 + access: result 73 + ? parseAccessFromString(result.access) 74 + : AgeAssuranceAccess.Full, 75 + } 76 + logger.debug('debug useAgeAssuranceState', { 77 + region, 78 + state, 79 + data, 80 + computed, 81 + }) 82 + return computed 83 + }, [hasSession, geolocation, config, state, data]) 84 + } 85 + 86 + export function useOnAgeAssuranceAccessUpdate( 87 + cb: (state: AgeAssuranceState) => void, 88 + ) { 89 + const state = useAgeAssuranceState() 90 + // start with null to ensure callback is called on first render 91 + const [prevAccess, setPrevAccess] = useState<AgeAssuranceAccess | null>(null) 92 + 93 + useEffect(() => { 94 + if (prevAccess !== state.access) { 95 + setPrevAccess(state.access) 96 + cb(state) 97 + logger.debug(`useOnAgeAssuranceAccessUpdate`, {state}) 98 + } 99 + }, [cb, state, prevAccess]) 100 + }
+53
src/ageAssurance/types.ts
··· 1 + import {logger} from '#/ageAssurance/logger' 2 + 3 + export enum AgeAssuranceAccess { 4 + Unknown = 'unknown', 5 + None = 'none', 6 + Safe = 'safe', 7 + Full = 'full', 8 + } 9 + 10 + export enum AgeAssuranceStatus { 11 + Unknown = 'unknown', 12 + Pending = 'pending', 13 + Assured = 'assured', 14 + Blocked = 'blocked', 15 + } 16 + 17 + export type AgeAssuranceState = { 18 + lastInitiatedAt?: string 19 + status: AgeAssuranceStatus 20 + access: AgeAssuranceAccess 21 + } 22 + 23 + export function parseStatusFromString(raw: string) { 24 + switch (raw) { 25 + case 'unknown': 26 + return AgeAssuranceStatus.Unknown 27 + case 'pending': 28 + return AgeAssuranceStatus.Pending 29 + case 'assured': 30 + return AgeAssuranceStatus.Assured 31 + case 'blocked': 32 + return AgeAssuranceStatus.Blocked 33 + default: 34 + logger.error(`parseStatusFromString: unknown status value: ${raw}`) 35 + return AgeAssuranceStatus.Unknown 36 + } 37 + } 38 + 39 + export function parseAccessFromString(raw: string) { 40 + switch (raw) { 41 + case 'unknown': 42 + return AgeAssuranceAccess.Unknown 43 + case 'none': 44 + return AgeAssuranceAccess.None 45 + case 'safe': 46 + return AgeAssuranceAccess.Safe 47 + case 'full': 48 + return AgeAssuranceAccess.Full 49 + default: 50 + logger.error(`parseAccessFromString: unknown access value: ${raw}`) 51 + return AgeAssuranceAccess.Full 52 + } 53 + }
+74
src/ageAssurance/useBeginAgeAssurance.ts
··· 1 + import {type AppBskyAgeassuranceBegin, AtpAgent} from '@atproto/api' 2 + import {useMutation} from '@tanstack/react-query' 3 + 4 + import {wait} from '#/lib/async/wait' 5 + import { 6 + DEV_ENV_APPVIEW, 7 + PUBLIC_APPVIEW, 8 + PUBLIC_APPVIEW_DID, 9 + } from '#/lib/constants' 10 + import {isNetworkError} from '#/lib/hooks/useCleanError' 11 + import {logger} from '#/logger' 12 + import {useAgent} from '#/state/session' 13 + import {usePatchAgeAssuranceServerState} from '#/ageAssurance' 14 + import {BLUESKY_PROXY_DID} from '#/env' 15 + import {useGeolocation} from '#/geolocation' 16 + 17 + const IS_DEV_ENV = BLUESKY_PROXY_DID !== PUBLIC_APPVIEW_DID 18 + const APPVIEW = IS_DEV_ENV ? DEV_ENV_APPVIEW : PUBLIC_APPVIEW 19 + 20 + export function useBeginAgeAssurance() { 21 + const agent = useAgent() 22 + const geolocation = useGeolocation() 23 + const patchAgeAssuranceStateResponse = usePatchAgeAssuranceServerState() 24 + 25 + return useMutation({ 26 + async mutationFn( 27 + props: Omit< 28 + AppBskyAgeassuranceBegin.InputSchema, 29 + 'countryCode' | 'regionCode' 30 + >, 31 + ) { 32 + const countryCode = geolocation?.countryCode 33 + const regionCode = geolocation?.regionCode 34 + if (!countryCode) { 35 + throw new Error(`Geolocation not available, cannot init age assurance.`) 36 + } 37 + 38 + const { 39 + data: {token}, 40 + } = await agent.com.atproto.server.getServiceAuth({ 41 + aud: BLUESKY_PROXY_DID, 42 + lxm: `app.bsky.ageassurance.begin`, 43 + }) 44 + 45 + const appView = new AtpAgent({service: APPVIEW}) 46 + appView.sessionManager.session = {...agent.session!} 47 + appView.sessionManager.session.accessJwt = token 48 + appView.sessionManager.session.refreshJwt = '' 49 + 50 + /* 51 + * 2s wait is good actually. Email sending takes a hot sec and this helps 52 + * ensure the email is ready for the user once they open their inbox. 53 + */ 54 + const {data} = await wait( 55 + 2e3, 56 + appView.app.bsky.ageassurance.begin({ 57 + ...props, 58 + countryCode: countryCode.toUpperCase(), 59 + regionCode: regionCode ? regionCode.toUpperCase() : undefined, 60 + }), 61 + ) 62 + 63 + // Just keeps this in sync, not necessarily used right now 64 + patchAgeAssuranceStateResponse(data) 65 + }, 66 + onError(e) { 67 + if (!isNetworkError(e)) { 68 + logger.error(`useBeginAgeAssurance failed`, { 69 + safeMessage: e, 70 + }) 71 + } 72 + }, 73 + }) 74 + }
+29
src/ageAssurance/useComputeAgeAssuranceRegionAccess.ts
··· 1 + import {useCallback} from 'react' 2 + import {computeAgeAssuranceRegionAccess} from '@atproto/api' 3 + 4 + import {useAgeAssuranceDataContext} from '#/ageAssurance/data' 5 + import {logger} from '#/ageAssurance/logger' 6 + import {AgeAssuranceAccess, parseAccessFromString} from '#/ageAssurance/types' 7 + import {getAgeAssuranceRegionConfigWithFallback} from '#/ageAssurance/util' 8 + import {type Geolocation} from '#/geolocation' 9 + 10 + export function useComputeAgeAssuranceRegionAccess() { 11 + const {config, data} = useAgeAssuranceDataContext() 12 + return useCallback( 13 + (geolocation: Geolocation) => { 14 + if (!config) { 15 + logger.warn('useComputeAgeAssuranceRegionAccess: missing config') 16 + return AgeAssuranceAccess.Unknown 17 + } 18 + const region = getAgeAssuranceRegionConfigWithFallback( 19 + config, 20 + geolocation, 21 + ) 22 + const result = computeAgeAssuranceRegionAccess(region, data) 23 + return result 24 + ? parseAccessFromString(result.access) 25 + : AgeAssuranceAccess.Full 26 + }, 27 + [config, data], 28 + ) 29 + }
+84
src/ageAssurance/util.ts
··· 1 + import {useMemo} from 'react' 2 + import { 3 + ageAssuranceRuleIDs as ids, 4 + type AppBskyAgeassuranceDefs, 5 + getAgeAssuranceRegionConfig, 6 + } from '@atproto/api' 7 + 8 + import {getAge} from '#/lib/strings/time' 9 + import {useAgeAssuranceDataContext} from '#/ageAssurance/data' 10 + import {AgeAssuranceAccess} from '#/ageAssurance/types' 11 + import {type Geolocation, useGeolocation} from '#/geolocation' 12 + 13 + const DEFAULT_MIN_AGE = 13 14 + 15 + /** 16 + * Get age assurance region config based on geolocation, with fallback to 17 + * app defaults if no region config is found. 18 + * 19 + * See {@link getAgeAssuranceRegionConfig} for the generic option, which can 20 + * return undefined if the geolocation does not match any AA region. 21 + */ 22 + export function getAgeAssuranceRegionConfigWithFallback( 23 + config: AppBskyAgeassuranceDefs.Config, 24 + geolocation: Geolocation, 25 + ): AppBskyAgeassuranceDefs.ConfigRegion { 26 + const region = getAgeAssuranceRegionConfig(config, { 27 + countryCode: geolocation.countryCode ?? '', 28 + regionCode: geolocation.regionCode, 29 + }) 30 + 31 + return ( 32 + region || { 33 + countryCode: '*', 34 + regionCode: undefined, 35 + rules: [ 36 + { 37 + $type: ids.IfDeclaredOverAge, 38 + age: DEFAULT_MIN_AGE, 39 + access: AgeAssuranceAccess.Full, 40 + }, 41 + { 42 + $type: ids.Default, 43 + access: AgeAssuranceAccess.None, 44 + }, 45 + ], 46 + } 47 + ) 48 + } 49 + 50 + /** 51 + * Hook to get the age assurance region config based on current geolocation. 52 + * Does not fall-back to our app defaults. If no config is found, returns 53 + * undefined, which indicates no regional age assurance rules apply. 54 + */ 55 + export function useAgeAssuranceRegionConfig() { 56 + const geolocation = useGeolocation() 57 + const {config} = useAgeAssuranceDataContext() 58 + return useMemo(() => { 59 + if (!config) return 60 + // use generic helper, we want to potentially return undefined 61 + return getAgeAssuranceRegionConfig(config, { 62 + countryCode: geolocation.countryCode ?? '', 63 + regionCode: geolocation.regionCode, 64 + }) 65 + }, [config, geolocation]) 66 + } 67 + 68 + /** 69 + * Some users may have erroneously set their birth date to the current date 70 + * if one wasn't set on their account. We previously didn't do validation on 71 + * the bday dialog, and it defaulted to the current date. This bug _has_ been 72 + * seen in production, so we need to check for it where possible. 73 + */ 74 + export function isLegacyBirthdateBug(birthDate: string) { 75 + return ['2025', '2024', '2023'].includes((birthDate || '').slice(0, 4)) 76 + } 77 + 78 + /** 79 + * Returns whether the user is under the minimum age required to use the app. 80 + * This applies to all regions. 81 + */ 82 + export function isUserUnderMinimumAge(birthDate: string) { 83 + return getAge(new Date(birthDate)) < DEFAULT_MIN_AGE 84 + }
-193
src/components/BlockedGeoOverlay.tsx
··· 1 - import {useEffect} from 'react' 2 - import {ScrollView, View} from 'react-native' 3 - import {useSafeAreaInsets} from 'react-native-safe-area-context' 4 - import {msg, Trans} from '@lingui/macro' 5 - import {useLingui} from '@lingui/react' 6 - 7 - import {logger} from '#/logger' 8 - import {isWeb} from '#/platform/detection' 9 - import {useDeviceGeolocationApi} from '#/state/geolocation' 10 - import {atoms as a, useBreakpoints, useTheme, web} from '#/alf' 11 - import {Button, ButtonIcon, ButtonText} from '#/components/Button' 12 - import * as Dialog from '#/components/Dialog' 13 - import {DeviceLocationRequestDialog} from '#/components/dialogs/DeviceLocationRequestDialog' 14 - import {Divider} from '#/components/Divider' 15 - import {Full as Logo, Mark} from '#/components/icons/Logo' 16 - import {PinLocation_Stroke2_Corner0_Rounded as LocationIcon} from '#/components/icons/PinLocation' 17 - import {SimpleInlineLinkText as InlineLinkText} from '#/components/Link' 18 - import {Outlet as PortalOutlet} from '#/components/Portal' 19 - import * as Toast from '#/components/Toast' 20 - import {Text} from '#/components/Typography' 21 - import {BottomSheetOutlet} from '#/../modules/bottom-sheet' 22 - 23 - export function BlockedGeoOverlay() { 24 - const t = useTheme() 25 - const {_} = useLingui() 26 - const {gtPhone} = useBreakpoints() 27 - const insets = useSafeAreaInsets() 28 - const geoDialog = Dialog.useDialogControl() 29 - const {setDeviceGeolocation} = useDeviceGeolocationApi() 30 - 31 - useEffect(() => { 32 - // just counting overall hits here 33 - logger.metric(`blockedGeoOverlay:shown`, {}) 34 - }, []) 35 - 36 - const textStyles = [a.text_md, a.leading_normal] 37 - const links = { 38 - blog: { 39 - to: `https://bsky.social/about/blog/08-22-2025-mississippi-hb1126`, 40 - label: _(msg`Read our blog post`), 41 - overridePresentation: false, 42 - disableMismatchWarning: true, 43 - style: textStyles, 44 - }, 45 - } 46 - 47 - const blocks = [ 48 - _(msg`Unfortunately, Bluesky is unavailable in Mississippi right now.`), 49 - _( 50 - msg`A new Mississippi law requires us to implement age verification for all users before they can access Bluesky. We think this law creates challenges that go beyond its child safety goals, and creates significant barriers that limit free speech and disproportionately harm smaller platforms and emerging technologies.`, 51 - ), 52 - _( 53 - msg`As a small team, we cannot justify building the expensive infrastructure this requirement demands while legal challenges to this law are pending.`, 54 - ), 55 - _( 56 - msg`For now, we have made the difficult decision to block access to Bluesky in the state of Mississippi.`, 57 - ), 58 - <> 59 - To learn more, read our{' '} 60 - <InlineLinkText {...links.blog}>blog post</InlineLinkText>. 61 - </>, 62 - ] 63 - 64 - return ( 65 - <> 66 - <ScrollView 67 - contentContainerStyle={[ 68 - a.px_2xl, 69 - { 70 - paddingTop: isWeb ? a.p_5xl.padding : insets.top + a.p_2xl.padding, 71 - paddingBottom: 100, 72 - }, 73 - ]}> 74 - <View 75 - style={[ 76 - a.mx_auto, 77 - web({ 78 - maxWidth: 380, 79 - paddingTop: gtPhone ? '8vh' : undefined, 80 - }), 81 - ]}> 82 - <View style={[a.align_start]}> 83 - <View 84 - style={[ 85 - a.pl_md, 86 - a.pr_lg, 87 - a.py_sm, 88 - a.rounded_full, 89 - a.flex_row, 90 - a.align_center, 91 - a.gap_xs, 92 - { 93 - backgroundColor: t.palette.primary_25, 94 - }, 95 - ]}> 96 - <Mark fill={t.palette.primary_600} width={14} /> 97 - <Text 98 - style={[ 99 - a.font_semi_bold, 100 - { 101 - color: t.palette.primary_600, 102 - }, 103 - ]}> 104 - <Trans>Announcement</Trans> 105 - </Text> 106 - </View> 107 - </View> 108 - 109 - <View style={[a.gap_lg, {paddingTop: 32}]}> 110 - {blocks.map((block, index) => ( 111 - <Text key={index} style={[textStyles]}> 112 - {block} 113 - </Text> 114 - ))} 115 - </View> 116 - 117 - {!isWeb && ( 118 - <> 119 - <View style={[a.pt_2xl]}> 120 - <Divider /> 121 - </View> 122 - 123 - <View style={[a.mt_xl, a.align_start]}> 124 - <Text style={[a.text_lg, a.font_bold, a.leading_snug, a.pb_xs]}> 125 - <Trans>Not in Mississippi?</Trans> 126 - </Text> 127 - <Text 128 - style={[ 129 - a.text_sm, 130 - a.leading_snug, 131 - t.atoms.text_contrast_medium, 132 - a.pb_md, 133 - ]}> 134 - <Trans> 135 - Confirm your location with GPS. Your location data is not 136 - tracked and does not leave your device. 137 - </Trans> 138 - </Text> 139 - <Button 140 - label={_(msg`Confirm your location`)} 141 - onPress={() => geoDialog.open()} 142 - size="small" 143 - color="primary_subtle"> 144 - <ButtonIcon icon={LocationIcon} /> 145 - <ButtonText> 146 - <Trans>Confirm your location</Trans> 147 - </ButtonText> 148 - </Button> 149 - </View> 150 - 151 - <DeviceLocationRequestDialog 152 - control={geoDialog} 153 - onLocationAcquired={props => { 154 - if (props.geolocationStatus.isAgeBlockedGeo) { 155 - props.disableDialogAction() 156 - props.setDialogError( 157 - _( 158 - msg`We're sorry, but based on your device's location, you are currently located in a region where we cannot provide access at this time.`, 159 - ), 160 - ) 161 - } else { 162 - props.closeDialog(() => { 163 - // set this after close! 164 - setDeviceGeolocation({ 165 - countryCode: props.geolocationStatus.countryCode, 166 - regionCode: props.geolocationStatus.regionCode, 167 - }) 168 - Toast.show(_(msg`Thanks! You're all set.`), { 169 - type: 'success', 170 - }) 171 - }) 172 - } 173 - }} 174 - /> 175 - </> 176 - )} 177 - 178 - <View style={[{paddingTop: 48}]}> 179 - <Logo width={120} textFill={t.atoms.text.color} /> 180 - </View> 181 - </View> 182 - </ScrollView> 183 - 184 - {/* 185 - * While this blocking overlay is up, other dialogs in the shell 186 - * are not mounted, so it _should_ be safe to use these here 187 - * without fear of other modals showing up. 188 - */} 189 - <BottomSheetOutlet /> 190 - <PortalOutlet /> 191 - </> 192 - ) 193 - }
+5 -3
src/components/Link.tsx
··· 421 421 label, 422 422 disableUnderline, 423 423 shouldProxy, 424 + onPress: outerOnPress, 424 425 ...rest 425 426 }: Omit< 426 427 InlineLinkProps, ··· 428 429 | 'action' 429 430 | 'disableMismatchWarning' 430 431 | 'overridePresentation' 431 - | 'onPress' 432 432 | 'onLongPress' 433 433 | 'shareOnLongPress' 434 434 > & { ··· 448 448 href = createProxiedUrl(href) 449 449 } 450 450 451 - const onPress = () => { 451 + const onPress = (e: GestureResponderEvent) => { 452 + const exitEarlyIfFalse = outerOnPress?.(e) 453 + if (exitEarlyIfFalse === false) return 452 454 Linking.openURL(href) 453 455 } 454 456 ··· 517 519 export function createStaticClick( 518 520 onPressHandler: Exclude<BaseLinkProps['onPress'], undefined>, 519 521 ): { 520 - to: BaseLinkProps['to'] 522 + to: string 521 523 onPress: Exclude<BaseLinkProps['onPress'], undefined> 522 524 } { 523 525 return {
+3 -3
src/components/PostControls/ShareMenu/ShareMenuItems.tsx
··· 11 11 import {toShareUrl} from '#/lib/strings/url-helpers' 12 12 import {logger} from '#/logger' 13 13 import {isIOS} from '#/platform/detection' 14 - import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance' 15 14 import {useProfileShadow} from '#/state/cache/profile-shadow' 16 15 import {useSession} from '#/state/session' 17 16 import * as Toast from '#/view/com/util/Toast' ··· 24 23 import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard' 25 24 import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlaneIcon} from '#/components/icons/PaperPlane' 26 25 import * as Menu from '#/components/Menu' 26 + import {useAgeAssurance} from '#/ageAssurance' 27 27 import {useDevMode} from '#/storage/hooks/dev-mode' 28 28 import {RecentChats} from './RecentChats' 29 29 import {type ShareMenuItemsProps} from './ShareMenuItems.types' ··· 37 37 const navigation = useNavigation<NavigationProp>() 38 38 const sendViaChatControl = useDialogControl() 39 39 const [devModeEnabled] = useDevMode() 40 - const {isAgeRestricted} = useAgeAssurance() 40 + const aa = useAgeAssurance() 41 41 42 42 const postUri = post.uri 43 43 const postAuthor = useProfileShadow(post.author) ··· 91 91 return ( 92 92 <> 93 93 <Menu.Outer> 94 - {hasSession && !isAgeRestricted && ( 94 + {hasSession && aa.state.access === aa.Access.Full && ( 95 95 <Menu.Group> 96 96 <Menu.ContainerItem> 97 97 <RecentChats postUri={postUri} />
+3 -3
src/components/PostControls/ShareMenu/ShareMenuItems.web.tsx
··· 10 10 import {toShareUrl} from '#/lib/strings/url-helpers' 11 11 import {logger} from '#/logger' 12 12 import {isWeb} from '#/platform/detection' 13 - import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance' 14 13 import {useProfileShadow} from '#/state/cache/profile-shadow' 15 14 import {useSession} from '#/state/session' 16 15 import {useBreakpoints} from '#/alf' ··· 22 21 import {CodeBrackets_Stroke2_Corner0_Rounded as CodeBracketsIcon} from '#/components/icons/CodeBrackets' 23 22 import {PaperPlane_Stroke2_Corner0_Rounded as Send} from '#/components/icons/PaperPlane' 24 23 import * as Menu from '#/components/Menu' 24 + import {useAgeAssurance} from '#/ageAssurance' 25 25 import {useDevMode} from '#/storage/hooks/dev-mode' 26 26 import {type ShareMenuItemsProps} from './ShareMenuItems.types' 27 27 ··· 38 38 const embedPostControl = useDialogControl() 39 39 const sendViaChatControl = useDialogControl() 40 40 const [devModeEnabled] = useDevMode() 41 - const {isAgeRestricted} = useAgeAssurance() 41 + const aa = useAgeAssurance() 42 42 43 43 const postUri = post.uri 44 44 const postCid = post.cid ··· 97 97 <Menu.Outer> 98 98 {!hideInPWI && copyLinkItem} 99 99 100 - {hasSession && !isAgeRestricted && ( 100 + {hasSession && aa.state.access === aa.Access.Full && ( 101 101 <Menu.Item 102 102 testID="postDropdownSendViaDMBtn" 103 103 label={_(msg`Send via direct message`)}
+14 -16
src/components/ageAssurance/AgeAssuranceAccountCard.tsx
··· 4 4 5 5 import {dateDiff, useGetTimeAgo} from '#/lib/hooks/useTimeAgo' 6 6 import {isNative} from '#/platform/detection' 7 - import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance' 8 - import {logger} from '#/state/ageAssurance/util' 9 - import {useDeviceGeolocationApi} from '#/state/geolocation' 10 7 import {atoms as a, useBreakpoints, useTheme, type ViewStyleProp} from '#/alf' 11 8 import {Admonition} from '#/components/Admonition' 12 9 import {AgeAssuranceAppealDialog} from '#/components/ageAssurance/AgeAssuranceAppealDialog' ··· 23 20 import {createStaticClick, InlineLinkText} from '#/components/Link' 24 21 import * as Toast from '#/components/Toast' 25 22 import {Text} from '#/components/Typography' 23 + import {logger, useAgeAssurance} from '#/ageAssurance' 24 + import {useComputeAgeAssuranceRegionAccess} from '#/ageAssurance/useComputeAgeAssuranceRegionAccess' 25 + import {useDeviceGeolocationApi} from '#/geolocation' 26 26 27 27 export function AgeAssuranceAccountCard({style}: ViewStyleProp & {}) { 28 - const {isReady, isAgeRestricted, isDeclaredUnderage} = useAgeAssurance() 29 - 30 - if (!isReady) return null 31 - if (isDeclaredUnderage) return null 32 - if (!isAgeRestricted) return null 33 - 28 + const aa = useAgeAssurance() 29 + if (aa.state.access === aa.Access.Full) return null 34 30 return <Inner style={style} /> 35 31 } 36 32 ··· 43 39 const getTimeAgo = useGetTimeAgo() 44 40 const {gtPhone} = useBreakpoints() 45 41 const {setDeviceGeolocation} = useDeviceGeolocationApi() 42 + const computeAgeAssuranceRegionAccess = useComputeAgeAssuranceRegionAccess() 46 43 47 44 const copy = useAgeAssuranceCopy() 48 - const {status, lastInitiatedAt} = useAgeAssurance() 49 - const isBlocked = status === 'blocked' 45 + const aa = useAgeAssurance() 46 + const {status, lastInitiatedAt} = aa.state 47 + const isBlocked = status === aa.Status.Blocked 50 48 const hasInitiated = !!lastInitiatedAt 51 49 const timeAgo = lastInitiatedAt 52 50 ? getTimeAgo(lastInitiatedAt, new Date()) ··· 98 96 <DeviceLocationRequestDialog 99 97 control={locationControl} 100 98 onLocationAcquired={props => { 101 - if (props.geolocationStatus.isAgeRestrictedGeo) { 99 + const access = computeAgeAssuranceRegionAccess( 100 + props.geolocation, 101 + ) 102 + if (access !== aa.Access.Full) { 102 103 props.disableDialogAction() 103 104 props.setDialogError( 104 105 _( ··· 108 109 } else { 109 110 props.closeDialog(() => { 110 111 // set this after close! 111 - setDeviceGeolocation({ 112 - countryCode: props.geolocationStatus.countryCode, 113 - regionCode: props.geolocationStatus.regionCode, 114 - }) 112 + setDeviceGeolocation(props.geolocation) 115 113 Toast.show(_(msg`Thanks! You're all set.`), { 116 114 type: 'success', 117 115 })
+4 -6
src/components/ageAssurance/AgeAssuranceAdmonition.tsx
··· 2 2 import {msg, Trans} from '@lingui/macro' 3 3 import {useLingui} from '@lingui/react' 4 4 5 - import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance' 6 - import {logger} from '#/state/ageAssurance/util' 7 5 import {atoms as a, select, useTheme, type ViewStyleProp} from '#/alf' 8 6 import {useDialogControl} from '#/components/ageAssurance/AgeAssuranceInitDialog' 9 7 import type * as Dialog from '#/components/Dialog' 10 8 import {ShieldCheck_Stroke2_Corner0_Rounded as Shield} from '#/components/icons/Shield' 11 9 import {InlineLinkText} from '#/components/Link' 12 10 import {Text} from '#/components/Typography' 11 + import {useAgeAssurance} from '#/ageAssurance' 12 + import {logger} from '#/ageAssurance' 13 13 14 14 export function AgeAssuranceAdmonition({ 15 15 children, 16 16 style, 17 17 }: ViewStyleProp & {children: React.ReactNode}) { 18 18 const control = useDialogControl() 19 - const {isReady, isDeclaredUnderage, isAgeRestricted} = useAgeAssurance() 19 + const aa = useAgeAssurance() 20 20 21 - if (!isReady) return null 22 - if (isDeclaredUnderage) return null 23 - if (!isAgeRestricted) return null 21 + if (aa.state.access === aa.Access.Full) return null 24 22 25 23 return ( 26 24 <Inner style={style} control={control}>
+1 -1
src/components/ageAssurance/AgeAssuranceAppealDialog.tsx
··· 6 6 import {useMutation} from '@tanstack/react-query' 7 7 8 8 import {BLUESKY_MOD_SERVICE_HEADERS} from '#/lib/constants' 9 - import {logger} from '#/state/ageAssurance/util' 10 9 import {useAgent, useSession} from '#/state/session' 11 10 import * as Toast from '#/view/com/util/Toast' 12 11 import {atoms as a, useBreakpoints, web} from '#/alf' ··· 15 14 import * as Dialog from '#/components/Dialog' 16 15 import {Loader} from '#/components/Loader' 17 16 import {Text} from '#/components/Typography' 17 + import {logger} from '#/ageAssurance' 18 18 19 19 export function AgeAssuranceAppealDialog({ 20 20 control,
+6 -16
src/components/ageAssurance/AgeAssuranceDismissibleFeedBanner.tsx
··· 3 3 import {msg} from '@lingui/macro' 4 4 import {useLingui} from '@lingui/react' 5 5 6 - import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance' 7 - import {logger} from '#/state/ageAssurance/util' 8 6 import {Nux, useNux, useSaveNux} from '#/state/queries/nuxs' 9 7 import {atoms as a, select, useTheme} from '#/alf' 10 8 import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy' ··· 13 11 import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' 14 12 import {Link} from '#/components/Link' 15 13 import {Text} from '#/components/Typography' 14 + import {useAgeAssurance} from '#/ageAssurance' 15 + import {logger} from '#/ageAssurance' 16 16 17 17 export function useInternalState() { 18 - const {isReady, isDeclaredUnderage, isAgeRestricted, lastInitiatedAt} = 19 - useAgeAssurance() 18 + const aa = useAgeAssurance() 20 19 const {nux} = useNux(Nux.AgeAssuranceDismissibleFeedBanner) 21 20 const {mutate: save, variables} = useSaveNux() 22 21 const hidden = !!variables 23 22 24 23 const visible = useMemo(() => { 25 - if (!isReady) return false 26 - if (isDeclaredUnderage) return false 27 - if (!isAgeRestricted) return false 28 - if (lastInitiatedAt) return false 24 + if (aa.state.access === aa.Access.Full) return false 25 + if (aa.state.lastInitiatedAt) return false 29 26 if (hidden) return false 30 27 if (nux && nux.completed) return false 31 28 return true 32 - }, [ 33 - isReady, 34 - isDeclaredUnderage, 35 - isAgeRestricted, 36 - lastInitiatedAt, 37 - hidden, 38 - nux, 39 - ]) 29 + }, [aa, hidden, nux]) 40 30 41 31 const close = () => { 42 32 save({
+5 -8
src/components/ageAssurance/AgeAssuranceDismissibleNotice.tsx
··· 2 2 import {msg} from '@lingui/macro' 3 3 import {useLingui} from '@lingui/react' 4 4 5 - import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance' 6 - import {logger} from '#/state/ageAssurance/util' 7 5 import {Nux, useNux, useSaveNux} from '#/state/queries/nuxs' 8 6 import {atoms as a, type ViewStyleProp} from '#/alf' 9 7 import {AgeAssuranceAdmonition} from '#/components/ageAssurance/AgeAssuranceAdmonition' 10 8 import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy' 11 9 import {Button, ButtonIcon} from '#/components/Button' 12 10 import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' 11 + import {useAgeAssurance} from '#/ageAssurance' 12 + import {logger} from '#/ageAssurance' 13 13 14 14 export function AgeAssuranceDismissibleNotice({style}: ViewStyleProp & {}) { 15 15 const {_} = useLingui() 16 - const {isReady, isDeclaredUnderage, isAgeRestricted, lastInitiatedAt} = 17 - useAgeAssurance() 16 + const aa = useAgeAssurance() 18 17 const {nux} = useNux(Nux.AgeAssuranceDismissibleNotice) 19 18 const copy = useAgeAssuranceCopy() 20 19 const {mutate: save, variables} = useSaveNux() 21 20 const hidden = !!variables 22 21 23 - if (!isReady) return null 24 - if (isDeclaredUnderage) return null 25 - if (!isAgeRestricted) return null 26 - if (lastInitiatedAt) return null 22 + if (aa.state.access === aa.Access.Full) return null 23 + if (aa.state.lastInitiatedAt) return null 27 24 if (hidden) return null 28 25 if (nux && nux.completed) return null 29 26
+16 -21
src/components/ageAssurance/AgeAssuranceInitDialog.tsx
··· 14 14 import {useTLDs} from '#/lib/hooks/useTLDs' 15 15 import {isEmailMaybeInvalid} from '#/lib/strings/email' 16 16 import {type AppLanguage} from '#/locale/languages' 17 - import {useAgeAssuranceContext} from '#/state/ageAssurance' 18 - import {useInitAgeAssurance} from '#/state/ageAssurance/useInitAgeAssurance' 19 - import {logger} from '#/state/ageAssurance/util' 20 17 import {useLanguagePrefs} from '#/state/preferences' 21 18 import {useSession} from '#/state/session' 22 19 import {atoms as a, useTheme, web} from '#/alf' ··· 30 27 import * as TextField from '#/components/forms/TextField' 31 28 import {ShieldCheck_Stroke2_Corner0_Rounded as Shield} from '#/components/icons/Shield' 32 29 import {LanguageSelect} from '#/components/LanguageSelect' 33 - import {InlineLinkText} from '#/components/Link' 30 + import {SimpleInlineLinkText} from '#/components/Link' 34 31 import {Loader} from '#/components/Loader' 35 32 import {Text} from '#/components/Typography' 33 + import {logger} from '#/ageAssurance' 34 + import {useAgeAssurance} from '#/ageAssurance' 35 + import {useBeginAgeAssurance} from '#/ageAssurance/useBeginAgeAssurance' 36 36 37 37 export {useDialogControl} from '#/components/Dialog/context' 38 38 ··· 69 69 const langPrefs = useLanguagePrefs() 70 70 const cleanError = useCleanError() 71 71 const {close} = Dialog.useDialogContext() 72 - const {lastInitiatedAt} = useAgeAssuranceContext() 72 + const aa = useAgeAssurance() 73 + const lastInitiatedAt = aa.state.lastInitiatedAt 73 74 const getTimeAgo = useGetTimeAgo() 74 75 const tlds = useTLDs() 75 76 const createSupportLink = useCreateSupportLink() ··· 88 89 ) 89 90 const [error, setError] = useState<React.ReactNode>(null) 90 91 91 - const {mutateAsync: init, isPending} = useInitAgeAssurance() 92 + const {mutateAsync: begin, isPending} = useBeginAgeAssurance() 92 93 93 94 const runEmailValidation = () => { 94 95 if (validateEmail(email)) { ··· 127 128 return 128 129 } 129 130 130 - await init({ 131 + await begin({ 131 132 email, 132 133 language, 133 134 }) ··· 150 151 <Trans> 151 152 We're having issues initializing the age assurance process for 152 153 your account. Please{' '} 153 - <InlineLinkText 154 + <SimpleInlineLinkText 154 155 to={createSupportLink({code: SupportCode.AA_DID, email})} 155 156 label={_(msg`Contact support`)}> 156 157 contact support 157 - </InlineLinkText>{' '} 158 + </SimpleInlineLinkText>{' '} 158 159 for assistance. 159 160 </Trans> 160 161 </> ··· 195 196 <Text style={[a.text_sm, a.leading_snug]}> 196 197 <Trans> 197 198 We have partnered with{' '} 198 - <InlineLinkText 199 - overridePresentation 200 - disableMismatchWarning 199 + <SimpleInlineLinkText 201 200 label={_(msg`KWS website`)} 202 201 to={urls.kwsHome} 203 202 style={[a.text_sm, a.leading_snug]}> 204 203 KWS 205 - </InlineLinkText>{' '} 204 + </SimpleInlineLinkText>{' '} 206 205 to verify that you’re an adult. When you click "Begin" below, 207 206 KWS will check if you have previously verified your age using 208 207 this email address for other games/services powered by KWS ··· 328 327 style={[a.text_xs, a.leading_snug, t.atoms.text_contrast_medium]}> 329 328 <Trans> 330 329 By continuing, you agree to the{' '} 331 - <InlineLinkText 332 - overridePresentation 333 - disableMismatchWarning 330 + <SimpleInlineLinkText 334 331 label={_(msg`KWS Terms of Use`)} 335 332 to={urls.kwsTermsOfUse} 336 333 style={[a.text_xs, a.leading_snug]}> 337 334 KWS Terms of Use 338 - </InlineLinkText>{' '} 335 + </SimpleInlineLinkText>{' '} 339 336 and acknowledge that KWS will store your verified status with 340 337 your hashed email address in accordance with the{' '} 341 - <InlineLinkText 342 - overridePresentation 343 - disableMismatchWarning 338 + <SimpleInlineLinkText 344 339 label={_(msg`KWS Privacy Policy`)} 345 340 to={urls.kwsPrivacyPolicy} 346 341 style={[a.text_xs, a.leading_snug]}> 347 342 KWS Privacy Policy 348 - </InlineLinkText> 343 + </SimpleInlineLinkText> 349 344 . This means you won’t need to verify again the next time you 350 345 use this email for other apps, games, and services powered by 351 346 KWS technology.
+6 -12
src/components/ageAssurance/AgeAssuranceRedirectDialog.tsx
··· 6 6 import {retry} from '#/lib/async/retry' 7 7 import {wait} from '#/lib/async/wait' 8 8 import {isNative} from '#/platform/detection' 9 - import {useAgeAssuranceAPIContext} from '#/state/ageAssurance' 10 - import {logger} from '#/state/ageAssurance/util' 11 9 import {useAgent} from '#/state/session' 12 10 import {atoms as a, useTheme, web} from '#/alf' 13 11 import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge' ··· 18 16 import {CircleInfo_Stroke2_Corner0_Rounded as ErrorIcon} from '#/components/icons/CircleInfo' 19 17 import {Loader} from '#/components/Loader' 20 18 import {Text} from '#/components/Typography' 19 + import {refetchAgeAssuranceServerState} from '#/ageAssurance' 20 + import {logger} from '#/ageAssurance' 21 21 22 22 export type AgeAssuranceRedirectDialogState = { 23 23 result: 'success' | 'unknown' ··· 63 63 const {_} = useLingui() 64 64 const control = useAgeAssuranceRedirectDialogControl() 65 65 66 - // TODO for testing 66 + // for testing 67 67 // Dialog.useAutoOpen(control.control, 3e3) 68 68 69 69 return ( ··· 88 88 const control = useAgeAssuranceRedirectDialogControl() 89 89 const [error, setError] = useState(false) 90 90 const [success, setSuccess] = useState(false) 91 - const {refetch: refreshAgeAssuranceState} = useAgeAssuranceAPIContext() 92 91 93 92 useEffect(() => { 94 93 if (polling.current) return ··· 106 105 if (!agent.session) return 107 106 if (unmounted.current) return 108 107 109 - const {data} = await agent.app.bsky.unspecced.getAgeAssuranceState() 108 + const data = await refetchAgeAssuranceServerState({agent}) 110 109 111 - if (data.status !== 'assured') { 110 + if (data?.state.status !== 'assured') { 112 111 throw new Error( 113 112 `Polling for age assurance state did not receive assured status`, 114 113 ) ··· 123 122 if (!data) return 124 123 if (!agent.session) return 125 124 if (unmounted.current) return 126 - 127 - // success! update state 128 - await refreshAgeAssuranceState() 129 125 130 126 setSuccess(true) 131 127 ··· 134 130 .catch(() => { 135 131 if (unmounted.current) return 136 132 setError(true) 137 - // try a refetch anyway 138 - refreshAgeAssuranceState() 139 133 logger.metric('ageAssurance:redirectDialogFail', {}) 140 134 }) 141 135 142 136 return () => { 143 137 unmounted.current = true 144 138 } 145 - }, [agent, control, refreshAgeAssuranceState]) 139 + }, [agent, control]) 146 140 147 141 if (success) { 148 142 return (
+4 -17
src/components/ageAssurance/AgeRestrictedScreen.tsx
··· 2 2 import {msg, Trans} from '@lingui/macro' 3 3 import {useLingui} from '@lingui/react' 4 4 5 - import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance' 6 - import {logger} from '#/state/ageAssurance/util' 7 5 import {atoms as a} from '#/alf' 8 6 import {Admonition} from '#/components/Admonition' 9 7 import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge' ··· 13 11 import * as Layout from '#/components/Layout' 14 12 import {Link} from '#/components/Link' 15 13 import {Text} from '#/components/Typography' 14 + import {useAgeAssurance} from '#/ageAssurance' 15 + import {logger} from '#/ageAssurance' 16 16 17 17 export function AgeRestrictedScreen({ 18 18 children, ··· 27 27 }) { 28 28 const {_} = useLingui() 29 29 const copy = useAgeAssuranceCopy() 30 - const {isReady, isAgeRestricted} = useAgeAssurance() 30 + const aa = useAgeAssurance() 31 31 32 - if (!isReady) { 33 - return ( 34 - <Layout.Screen> 35 - <Layout.Header.Outer> 36 - <Layout.Header.Content> 37 - <Layout.Header.TitleText> </Layout.Header.TitleText> 38 - </Layout.Header.Content> 39 - <Layout.Header.Slot /> 40 - </Layout.Header.Outer> 41 - <Layout.Content /> 42 - </Layout.Screen> 43 - ) 44 - } 45 - if (!isAgeRestricted) return children 32 + if (aa.state.access === aa.Access.Full) return children 46 33 47 34 return ( 48 35 <Layout.Screen>
+12 -4
src/components/ageAssurance/useAgeAssuranceCopy.ts
··· 2 2 import {msg} from '@lingui/macro' 3 3 import {useLingui} from '@lingui/react' 4 4 5 + import {useAgeAssurance} from '#/ageAssurance' 6 + 5 7 export function useAgeAssuranceCopy() { 6 8 const {_} = useLingui() 9 + const aa = useAgeAssurance() 7 10 8 11 return useMemo(() => { 9 12 return { 10 - notice: _( 11 - msg`The laws in your location require you to verify you're an adult before accessing certain features on Bluesky, like adult content and direct messaging.`, 12 - ), 13 + notice: 14 + aa.state.access === aa.Access.Safe 15 + ? _( 16 + msg`Due to laws in your region, certain features on Bluesky are currently restricted until you're able to verify you're an adult.`, 17 + ) 18 + : _( 19 + msg`The laws in your location require you to verify you're an adult before accessing certain features on Bluesky, like adult content and direct messaging.`, 20 + ), 13 21 banner: _( 14 22 msg`The laws in your location require you to verify you're an adult to access certain features. Tap to learn more.`, 15 23 ), ··· 17 25 msg`Don't worry! All existing messages and settings are saved and will be available after you verify you're an adult.`, 18 26 ), 19 27 } 20 - }, [_]) 28 + }, [_, aa]) 21 29 }
+70 -38
src/components/dialogs/BirthDateSettings.tsx
··· 8 8 import {logger} from '#/logger' 9 9 import {isIOS, isWeb} from '#/platform/detection' 10 10 import { 11 + useBirthdateMutation, 12 + useIsBirthdateUpdateAllowed, 13 + } from '#/state/birthdate' 14 + import { 11 15 usePreferencesQuery, 12 16 type UsePreferencesQueryResponse, 13 - usePreferencesSetBirthDateMutation, 14 17 } from '#/state/queries/preferences' 15 18 import {ErrorMessage} from '#/view/com/util/error/ErrorMessage' 16 19 import {atoms as a, useTheme, web} from '#/alf' ··· 18 21 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 19 22 import * as Dialog from '#/components/Dialog' 20 23 import {DateField} from '#/components/forms/DateField' 21 - import {InlineLinkText} from '#/components/Link' 24 + import {SimpleInlineLinkText} from '#/components/Link' 22 25 import {Loader} from '#/components/Loader' 23 26 import {Text} from '#/components/Typography' 24 27 ··· 30 33 const t = useTheme() 31 34 const {_} = useLingui() 32 35 const {isLoading, error, data: preferences} = usePreferencesQuery() 36 + const isBirthdateUpdateAllowed = useIsBirthdateUpdateAllowed() 33 37 34 38 return ( 35 39 <Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}> 36 40 <Dialog.Handle /> 37 - <Dialog.ScrollableInner 38 - label={_(msg`My Birthday`)} 39 - style={web({maxWidth: 400})}> 40 - <View style={[a.gap_sm]}> 41 - <Text style={[a.text_xl, a.font_semi_bold]}> 42 - <Trans>My Birthday</Trans> 43 - </Text> 44 - <Text style={[a.leading_snug, t.atoms.text_contrast_medium]}> 45 - <Trans> 46 - This information is private and not shared with other users. 47 - </Trans> 48 - </Text> 41 + {isBirthdateUpdateAllowed ? ( 42 + <Dialog.ScrollableInner 43 + label={_(msg`My Birthdate`)} 44 + style={web({maxWidth: 400})}> 45 + <View style={[a.gap_md]}> 46 + <Text style={[a.text_xl, a.font_semi_bold]}> 47 + <Trans>My Birthdate</Trans> 48 + </Text> 49 + <Text 50 + style={[a.text_md, a.leading_snug, t.atoms.text_contrast_medium]}> 51 + <Trans> 52 + This information is private and not shared with other users. 53 + </Trans> 54 + </Text> 49 55 50 - {isLoading ? ( 51 - <Loader size="xl" /> 52 - ) : error || !preferences ? ( 53 - <ErrorMessage 54 - message={ 55 - error?.toString() || 56 - _( 57 - msg`We were unable to load your birth date preferences. Please try again.`, 58 - ) 59 - } 60 - style={[a.rounded_sm]} 61 - /> 62 - ) : ( 63 - <BirthdayInner control={control} preferences={preferences} /> 64 - )} 65 - </View> 56 + {isLoading ? ( 57 + <Loader size="xl" /> 58 + ) : error || !preferences ? ( 59 + <ErrorMessage 60 + message={ 61 + error?.toString() || 62 + _( 63 + msg`We were unable to load your birthdate preferences. Please try again.`, 64 + ) 65 + } 66 + style={[a.rounded_sm]} 67 + /> 68 + ) : ( 69 + <BirthdayInner control={control} preferences={preferences} /> 70 + )} 71 + </View> 66 72 67 - <Dialog.Close /> 68 - </Dialog.ScrollableInner> 73 + <Dialog.Close /> 74 + </Dialog.ScrollableInner> 75 + ) : ( 76 + <Dialog.ScrollableInner 77 + label={_(msg`You recently changed your birthdate`)} 78 + style={web({maxWidth: 400})}> 79 + <View style={[a.gap_sm]}> 80 + <Text 81 + style={[ 82 + a.text_xl, 83 + a.font_semi_bold, 84 + a.leading_snug, 85 + {paddingRight: 32}, 86 + ]}> 87 + <Trans>You recently changed your birthdate</Trans> 88 + </Text> 89 + <Text 90 + style={[a.text_md, a.leading_snug, t.atoms.text_contrast_medium]}> 91 + <Trans> 92 + There is a limit to how often you can change your birthdate. You 93 + may need to wait a day or two before updating it again. 94 + </Trans> 95 + </Text> 96 + </View> 97 + 98 + <Dialog.Close /> 99 + </Dialog.ScrollableInner> 100 + )} 69 101 </Dialog.Outer> 70 102 ) 71 103 } ··· 86 118 isError, 87 119 error, 88 120 mutateAsync: setBirthDate, 89 - } = usePreferencesSetBirthDateMutation() 121 + } = useBirthdateMutation() 90 122 const hasChanged = date !== preferences.birthDate 91 123 92 124 const age = getAge(new Date(date)) ··· 112 144 testID="birthdayInput" 113 145 value={date} 114 146 onChangeDate={newDate => setDate(new Date(newDate))} 115 - label={_(msg`Birthday`)} 116 - accessibilityHint={_(msg`Enter your birth date`)} 147 + label={_(msg`Birthdate`)} 148 + accessibilityHint={_(msg`Enter your birthdate`)} 117 149 /> 118 150 </View> 119 151 ··· 130 162 <Admonition type="error"> 131 163 <Trans> 132 164 You must be at least 13 years old to use Bluesky. Read our{' '} 133 - <InlineLinkText 165 + <SimpleInlineLinkText 134 166 to="https://bsky.social/about/support/tos" 135 167 label={_(msg`Terms of Service`)}> 136 168 Terms of Service 137 - </InlineLinkText>{' '} 169 + </SimpleInlineLinkText>{' '} 138 170 for more information. 139 171 </Trans> 140 172 </Admonition> ··· 146 178 147 179 <View style={isWeb && [a.flex_row, a.justify_end]}> 148 180 <Button 149 - label={hasChanged ? _(msg`Save birthday`) : _(msg`Done`)} 181 + label={hasChanged ? _(msg`Save birthdate`) : _(msg`Done`)} 150 182 size="large" 151 183 onPress={onSave} 152 184 variant="solid"
+4 -11
src/components/dialogs/DeviceLocationRequestDialog.tsx
··· 7 7 import {isNetworkError, useCleanError} from '#/lib/hooks/useCleanError' 8 8 import {logger} from '#/logger' 9 9 import {isWeb} from '#/platform/detection' 10 - import { 11 - computeGeolocationStatus, 12 - type GeolocationStatus, 13 - useGeolocationConfig, 14 - } from '#/state/geolocation' 15 - import {useRequestDeviceLocation} from '#/state/geolocation/useRequestDeviceLocation' 16 10 import {atoms as a, useTheme, web} from '#/alf' 17 11 import {Admonition} from '#/components/Admonition' 18 12 import {Button, ButtonIcon, ButtonText} from '#/components/Button' ··· 20 14 import {PinLocation_Stroke2_Corner0_Rounded as LocationIcon} from '#/components/icons/PinLocation' 21 15 import {Loader} from '#/components/Loader' 22 16 import {Text} from '#/components/Typography' 17 + import {type Geolocation, useRequestDeviceGeolocation} from '#/geolocation' 23 18 24 19 export type Props = { 25 20 onLocationAcquired?: (props: { 26 - geolocationStatus: GeolocationStatus 21 + geolocation: Geolocation 27 22 setDialogError: (error: string) => void 28 23 disableDialogAction: () => void 29 24 closeDialog: (callback?: () => void) => void ··· 57 52 const t = useTheme() 58 53 const {_} = useLingui() 59 54 const {close} = Dialog.useDialogContext() 60 - const requestDeviceLocation = useRequestDeviceLocation() 61 - const {config} = useGeolocationConfig() 55 + const requestDeviceLocation = useRequestDeviceGeolocation() 62 56 const cleanError = useCleanError() 63 57 64 58 const [isRequesting, setIsRequesting] = useState(false) ··· 76 70 const location = req.location 77 71 78 72 if (location && location.countryCode) { 79 - const geolocationStatus = computeGeolocationStatus(location, config) 80 73 onLocationAcquired?.({ 81 - geolocationStatus, 74 + geolocation: location, 82 75 setDialogError: setError, 83 76 disableDialogAction: () => setDialogDisabled(true), 84 77 closeDialog: close,
+5 -8
src/env/common.ts
··· 100 100 : Number(process.env.EXPO_PUBLIC_GCP_PROJECT_ID) 101 101 102 102 /** 103 - * URL for the bapp-config web worker _development_ environment. Can be a 103 + * URLs for the app config web worker. Can be a 104 104 * locally running server, see `env.example` for more. 105 105 */ 106 106 export const BAPP_CONFIG_DEV_URL = process.env.BAPP_CONFIG_DEV_URL 107 - 108 - /** 109 - * Dev environment passthrough value for bapp-config web worker. Allows local 110 - * dev access to the web worker running in `development` mode. 111 - */ 112 - export const BAPP_CONFIG_DEV_BYPASS_SECRET: string = 113 - process.env.BAPP_CONFIG_DEV_BYPASS_SECRET 107 + export const BAPP_CONFIG_PROD_URL = `https://ip.bsky.app` 108 + export const BAPP_CONFIG_URL = IS_DEV 109 + ? (BAPP_CONFIG_DEV_URL ?? BAPP_CONFIG_PROD_URL) 110 + : BAPP_CONFIG_PROD_URL
+12
src/geolocation/const.ts
··· 1 + import {BAPP_CONFIG_URL} from '#/env' 2 + import {type Geolocation} from '#/geolocation/types' 3 + 4 + export const GEOLOCATION_SERVICE_URL = `${BAPP_CONFIG_URL}/geolocation` 5 + 6 + /** 7 + * Default geolocation config. 8 + */ 9 + export const FALLBACK_GEOLOCATION_SERVICE_RESPONSE: Geolocation = { 10 + countryCode: undefined, 11 + regionCode: undefined, 12 + }
+19
src/geolocation/debug.ts
··· 1 + import * as aaDebug from '#/ageAssurance/debug' 2 + import {IS_DEV} from '#/env' 3 + import {type Geolocation} from '#/geolocation/types' 4 + 5 + const localEnabled = false 6 + export const enabled = IS_DEV && (localEnabled || aaDebug.geolocation) 7 + export const geolocation: Geolocation = aaDebug.geolocation ?? { 8 + countryCode: 'AU', 9 + regionCode: undefined, 10 + } 11 + export const deviceGeolocation: Geolocation = aaDebug.deviceGeolocation ?? { 12 + countryCode: 'AU', 13 + regionCode: undefined, 14 + } 15 + 16 + export async function resolve<T>(data: T) { 17 + await new Promise(y => setTimeout(y, 2000)) // simulate network 18 + return data 19 + }
+144
src/geolocation/device.ts
··· 1 + import {useCallback, useEffect, useRef} from 'react' 2 + import * as Location from 'expo-location' 3 + import {createPermissionHook} from 'expo-modules-core' 4 + 5 + import {isNative} from '#/platform/detection' 6 + import * as debug from '#/geolocation/debug' 7 + import {logger} from '#/geolocation/logger' 8 + import {type Geolocation} from '#/geolocation/types' 9 + import {normalizeDeviceLocation} from '#/geolocation/util' 10 + import {device} from '#/storage' 11 + 12 + /** 13 + * Location.useForegroundPermissions on web just errors if the 14 + * navigator.permissions API is not available. We need to catch and ignore it, 15 + * since it's effectively denied. 16 + * 17 + * @see https://github.com/expo/expo/blob/72f1562ed9cce5ff6dfe04aa415b71632a3d4b87/packages/expo-location/src/Location.ts#L290-L293 18 + */ 19 + const useForegroundPermissions = createPermissionHook({ 20 + getMethod: () => 21 + Location.getForegroundPermissionsAsync().catch(error => { 22 + logger.debug( 23 + 'useForegroundPermission: error getting location permissions', 24 + {safeMessage: error}, 25 + ) 26 + return { 27 + status: Location.PermissionStatus.DENIED, 28 + granted: false, 29 + canAskAgain: false, 30 + expires: 0, 31 + } 32 + }), 33 + requestMethod: () => 34 + Location.requestForegroundPermissionsAsync().catch(error => { 35 + logger.debug( 36 + 'useForegroundPermission: error requesting location permissions', 37 + {safeMessage: error}, 38 + ) 39 + return { 40 + status: Location.PermissionStatus.DENIED, 41 + granted: false, 42 + canAskAgain: false, 43 + expires: 0, 44 + } 45 + }), 46 + }) 47 + 48 + export async function getDeviceGeolocation(): Promise<Geolocation> { 49 + if (debug.enabled) return debug.resolve(debug.deviceGeolocation) 50 + 51 + try { 52 + const geocode = await Location.getCurrentPositionAsync() 53 + const locations = await Location.reverseGeocodeAsync({ 54 + latitude: geocode.coords.latitude, 55 + longitude: geocode.coords.longitude, 56 + }) 57 + const location = locations.at(0) 58 + const normalized = location ? normalizeDeviceLocation(location) : undefined 59 + return { 60 + countryCode: normalized?.countryCode ?? undefined, 61 + regionCode: normalized?.regionCode ?? undefined, 62 + } 63 + } catch (e) { 64 + logger.error('getDeviceGeolocation: failed', {safeMessage: e}) 65 + return { 66 + countryCode: undefined, 67 + regionCode: undefined, 68 + } 69 + } 70 + } 71 + 72 + export function useRequestDeviceGeolocation(): () => Promise< 73 + | { 74 + granted: true 75 + location: Geolocation | undefined 76 + } 77 + | { 78 + granted: false 79 + } 80 + > { 81 + return useCallback(async () => { 82 + const status = await Location.requestForegroundPermissionsAsync() 83 + if (status.granted) { 84 + return { 85 + granted: true, 86 + location: await getDeviceGeolocation(), 87 + } 88 + } else { 89 + return { 90 + granted: false, 91 + } 92 + } 93 + }, []) 94 + } 95 + 96 + /** 97 + * Hook to get and sync the device geolocation from the device GPS and store it 98 + * using device storage. If permissions are not granted, it will clear any cached 99 + * storage value. 100 + */ 101 + export function useSyncDeviceGeolocationOnStartup( 102 + sync: (location: Geolocation | undefined) => void, 103 + ) { 104 + const synced = useRef(false) 105 + const [status] = useForegroundPermissions() 106 + useEffect(() => { 107 + if (!isNative) return 108 + 109 + async function get() { 110 + // no need to set this more than once per session 111 + if (synced.current) return 112 + logger.debug('useSyncDeviceGeolocationOnStartup: checking perms') 113 + if (status?.granted) { 114 + const location = await getDeviceGeolocation() 115 + if (location) { 116 + logger.debug('useSyncDeviceGeolocationOnStartup: got location') 117 + sync(location) 118 + synced.current = true 119 + } 120 + } else { 121 + const hasCachedValue = device.get(['deviceGeolocation']) !== undefined 122 + /** 123 + * If we have a cached value, but user has revoked permissions, 124 + * quietly (will take effect lazily) clear this out. 125 + */ 126 + if (hasCachedValue) { 127 + logger.debug( 128 + 'useSyncDeviceGeolocationOnStartup: clearing cached location, perms revoked', 129 + ) 130 + device.set(['deviceGeolocation'], undefined) 131 + } 132 + } 133 + } 134 + 135 + get().catch(e => { 136 + logger.error( 137 + 'useSyncDeviceGeolocationOnStartup: failed to get location', 138 + { 139 + safeMessage: e, 140 + }, 141 + ) 142 + }) 143 + }, [status, sync]) 144 + }
+65
src/geolocation/index.tsx
··· 1 + import { 2 + createContext, 3 + type ReactNode, 4 + useContext, 5 + useEffect, 6 + useMemo, 7 + } from 'react' 8 + 9 + import {useSyncDeviceGeolocationOnStartup} from '#/geolocation/device' 10 + import {useGeolocationServiceResponse} from '#/geolocation/service' 11 + import {type Geolocation} from '#/geolocation/types' 12 + import {mergeGeolocations} from '#/geolocation/util' 13 + import {device, useStorage} from '#/storage' 14 + 15 + export {useRequestDeviceGeolocation} from '#/geolocation/device' 16 + export {resolve} from '#/geolocation/service' 17 + export * from '#/geolocation/types' 18 + 19 + const GeolocationContext = createContext<Geolocation>({ 20 + countryCode: undefined, 21 + regionCode: undefined, 22 + }) 23 + 24 + const DeviceGeolocationAPIContext = createContext<{ 25 + setDeviceGeolocation(deviceGeolocation: Geolocation): void 26 + }>({ 27 + setDeviceGeolocation: () => {}, 28 + }) 29 + 30 + export function useGeolocation() { 31 + return useContext(GeolocationContext) 32 + } 33 + 34 + export function useDeviceGeolocationApi() { 35 + return useContext(DeviceGeolocationAPIContext) 36 + } 37 + 38 + export function Provider({children}: {children: ReactNode}) { 39 + const geolocationService = useGeolocationServiceResponse() 40 + const [deviceGeolocation, setDeviceGeolocation] = useStorage(device, [ 41 + 'deviceGeolocation', 42 + ]) 43 + const geolocation = useMemo(() => { 44 + return mergeGeolocations(deviceGeolocation, geolocationService) 45 + }, [deviceGeolocation, geolocationService]) 46 + 47 + useEffect(() => { 48 + /** 49 + * Save this for out-of-band-reads during future cold starts of the app. 50 + * Needs to be available for the data prefetching we do on boot. 51 + */ 52 + device.set(['mergedGeolocation'], geolocation) 53 + }, [geolocation]) 54 + 55 + useSyncDeviceGeolocationOnStartup(setDeviceGeolocation) 56 + 57 + return ( 58 + <GeolocationContext.Provider value={geolocation}> 59 + <DeviceGeolocationAPIContext.Provider 60 + value={useMemo(() => ({setDeviceGeolocation}), [setDeviceGeolocation])}> 61 + {children} 62 + </DeviceGeolocationAPIContext.Provider> 63 + </GeolocationContext.Provider> 64 + ) 65 + }
+136
src/geolocation/service.ts
··· 1 + import {useEffect, useState} from 'react' 2 + import EventEmitter from 'eventemitter3' 3 + 4 + import {networkRetry} from '#/lib/async/retry' 5 + import { 6 + FALLBACK_GEOLOCATION_SERVICE_RESPONSE, 7 + GEOLOCATION_SERVICE_URL, 8 + } from '#/geolocation/const' 9 + import * as debug from '#/geolocation/debug' 10 + import {logger} from '#/geolocation/logger' 11 + import {type Geolocation} from '#/geolocation/types' 12 + import {device} from '#/storage' 13 + 14 + const events = new EventEmitter() 15 + const EVENT = 'geolocation-service-response-updated' 16 + const emitGeolocationServiceResponseUpdate = (data: Geolocation) => { 17 + events.emit(EVENT, data) 18 + } 19 + const onGeolocationServiceResponseUpdate = ( 20 + listener: (data: Geolocation) => void, 21 + ) => { 22 + events.on(EVENT, listener) 23 + return () => { 24 + events.off(EVENT, listener) 25 + } 26 + } 27 + 28 + async function fetchGeolocationServiceData( 29 + url: string, 30 + ): Promise<Geolocation | undefined> { 31 + if (debug.enabled) return debug.resolve(debug.geolocation) 32 + const res = await fetch(url) 33 + if (!res.ok) { 34 + throw new Error(`fetchGeolocationServiceData failed ${res.status}`) 35 + } 36 + return res.json() as Promise<Geolocation> 37 + } 38 + 39 + /** 40 + * Local promise used within this file only. 41 + */ 42 + let geolocationServicePromise: Promise<{success: boolean}> | undefined 43 + 44 + /** 45 + * Begin the process of resolving geolocation config. This is called right away 46 + * at app start, and the promise is awaited later before proceeding with app 47 + * startup. 48 + */ 49 + export async function resolve() { 50 + if (geolocationServicePromise) { 51 + const cached = device.get(['geolocationServiceResponse']) 52 + if (cached) { 53 + logger.debug(`resolve(): using cache`) 54 + } else { 55 + logger.debug(`resolve(): no cache`) 56 + const {success} = await geolocationServicePromise 57 + if (success) { 58 + logger.debug(`resolve(): resolved`) 59 + } else { 60 + logger.info(`resolve(): failed`) 61 + } 62 + } 63 + } else { 64 + logger.debug(`resolve(): initiating`) 65 + 66 + /** 67 + * THIS PROMISE SHOULD NEVER `reject()`! We want the app to proceed with 68 + * startup, even if geolocation resolution fails. 69 + */ 70 + geolocationServicePromise = new Promise(async resolve => { 71 + let success = false 72 + 73 + function cacheResponseOrThrow(response: Geolocation | undefined) { 74 + if (response) { 75 + device.set(['geolocationServiceResponse'], response) 76 + emitGeolocationServiceResponseUpdate(response) 77 + } else { 78 + // endpoint should throw on all failures, this is insurance 79 + throw new Error(`fetchGeolocationServiceData returned no data`) 80 + } 81 + } 82 + 83 + try { 84 + // Try once, fail fast 85 + const config = await fetchGeolocationServiceData( 86 + GEOLOCATION_SERVICE_URL, 87 + ) 88 + cacheResponseOrThrow(config) 89 + success = true 90 + } catch (e: any) { 91 + logger.debug( 92 + `resolve(): fetchGeolocationServiceData failed initial request`, 93 + { 94 + safeMessage: e.message, 95 + }, 96 + ) 97 + 98 + // retry 3 times, but don't await, proceed with default 99 + networkRetry(3, () => 100 + fetchGeolocationServiceData(GEOLOCATION_SERVICE_URL), 101 + ) 102 + .then(config => { 103 + cacheResponseOrThrow(config) 104 + }) 105 + .catch((e: any) => { 106 + // complete fail closed 107 + logger.debug( 108 + `resolve(): fetchGeolocationServiceData failed retries`, 109 + { 110 + safeMessage: e.message, 111 + }, 112 + ) 113 + }) 114 + } finally { 115 + resolve({success}) 116 + } 117 + }) 118 + } 119 + } 120 + 121 + export function useGeolocationServiceResponse() { 122 + const [config, setConfig] = useState(() => { 123 + const initial = 124 + device.get(['geolocationServiceResponse']) || 125 + FALLBACK_GEOLOCATION_SERVICE_RESPONSE 126 + return initial 127 + }) 128 + 129 + useEffect(() => { 130 + return onGeolocationServiceResponseUpdate(config => { 131 + setConfig(config!) 132 + }) 133 + }, []) 134 + 135 + return config 136 + }
+4
src/geolocation/types.ts
··· 1 + export type Geolocation = { 2 + countryCode: string | undefined 3 + regionCode: string | undefined 4 + }
+113
src/geolocation/util.ts
··· 1 + import {type LocationGeocodedAddress} from 'expo-location' 2 + 3 + import {logger} from '#/geolocation/logger' 4 + import {type Geolocation} from '#/geolocation/types' 5 + 6 + /** 7 + * Maps full US region names to their short codes. 8 + * 9 + * Context: in some cases, like on Android, we get the full region name instead 10 + * of the short code. We may need to expand this in the future to other 11 + * countries, hence the prefix. 12 + */ 13 + export const USRegionNameToRegionCode: { 14 + [regionName: string]: string 15 + } = { 16 + Alabama: 'AL', 17 + Alaska: 'AK', 18 + Arizona: 'AZ', 19 + Arkansas: 'AR', 20 + California: 'CA', 21 + Colorado: 'CO', 22 + Connecticut: 'CT', 23 + Delaware: 'DE', 24 + Florida: 'FL', 25 + Georgia: 'GA', 26 + Hawaii: 'HI', 27 + Idaho: 'ID', 28 + Illinois: 'IL', 29 + Indiana: 'IN', 30 + Iowa: 'IA', 31 + Kansas: 'KS', 32 + Kentucky: 'KY', 33 + Louisiana: 'LA', 34 + Maine: 'ME', 35 + Maryland: 'MD', 36 + Massachusetts: 'MA', 37 + Michigan: 'MI', 38 + Minnesota: 'MN', 39 + Mississippi: 'MS', 40 + Missouri: 'MO', 41 + Montana: 'MT', 42 + Nebraska: 'NE', 43 + Nevada: 'NV', 44 + ['New Hampshire']: 'NH', 45 + ['New Jersey']: 'NJ', 46 + ['New Mexico']: 'NM', 47 + ['New York']: 'NY', 48 + ['North Carolina']: 'NC', 49 + ['North Dakota']: 'ND', 50 + Ohio: 'OH', 51 + Oklahoma: 'OK', 52 + Oregon: 'OR', 53 + Pennsylvania: 'PA', 54 + ['Rhode Island']: 'RI', 55 + ['South Carolina']: 'SC', 56 + ['South Dakota']: 'SD', 57 + Tennessee: 'TN', 58 + Texas: 'TX', 59 + Utah: 'UT', 60 + Vermont: 'VT', 61 + Virginia: 'VA', 62 + Washington: 'WA', 63 + ['West Virginia']: 'WV', 64 + Wisconsin: 'WI', 65 + Wyoming: 'WY', 66 + } 67 + 68 + /** 69 + * Normalizes a `LocationGeocodedAddress` into a `Geolocation`. 70 + * 71 + * We don't want or care about the full location data, so we trim it down and 72 + * normalize certain fields, like region, into the format we need. 73 + */ 74 + export function normalizeDeviceLocation( 75 + location: LocationGeocodedAddress, 76 + ): Geolocation { 77 + let {isoCountryCode, region} = location 78 + 79 + if (region) { 80 + if (isoCountryCode === 'US') { 81 + region = USRegionNameToRegionCode[region] ?? region 82 + } 83 + } 84 + 85 + return { 86 + countryCode: isoCountryCode ?? undefined, 87 + regionCode: region ?? undefined, 88 + } 89 + } 90 + 91 + /** 92 + * Combines precise location data with the geolocation config fetched from the 93 + * IP service, with preference to the precise data. 94 + */ 95 + export function mergeGeolocations( 96 + device?: Geolocation, 97 + geolocationService?: Geolocation, 98 + ): Geolocation { 99 + let geolocation: Geolocation = { 100 + countryCode: geolocationService?.countryCode ?? undefined, 101 + regionCode: geolocationService?.regionCode ?? undefined, 102 + } 103 + // prefer GPS 104 + if (device?.countryCode) { 105 + geolocation = device 106 + } 107 + logger.debug('merged geolocation data', { 108 + device, 109 + service: geolocationService, 110 + merged: geolocation, 111 + }) 112 + return geolocation 113 + }
+23
src/lib/__tests__/parseLinkingUrl.test.ts
··· 1 + import {describe, expect, it} from '@jest/globals' 2 + 3 + import {parseLinkingUrl} from '../parseLinkingUrl' 4 + 5 + describe('parseLinkingUrl', () => { 6 + it('should correctly parse bluesky:// URLs', () => { 7 + const url = 8 + 'bluesky://intent/age-assurance?result=success&actorDid=did:example:123' 9 + const urlp = parseLinkingUrl(url) 10 + expect(urlp.protocol).toBe('bluesky:') 11 + expect(urlp.host).toBe('') 12 + expect(urlp.pathname).toBe('/intent/age-assurance') 13 + }) 14 + 15 + it('should correctly parse standard URLs', () => { 16 + const url = 17 + 'https://bsky.app/intent/age-assurance?result=success&actorDid=did:example:123' 18 + const urlp = parseLinkingUrl(url) 19 + expect(urlp.protocol).toBe('https:') 20 + expect(urlp.host).toBe('bsky.app') 21 + expect(urlp.pathname).toBe('/intent/age-assurance') 22 + }) 23 + })
+1
src/lib/api/resolve.ts
··· 163 163 const res = await agent.resolveHandle({ 164 164 handle: urip.host, 165 165 }) 166 + // @ts-expect-error TODO new-sdk-migration 166 167 urip.host = res.data.did 167 168 } 168 169 const res = await agent.getPosts({
+1
src/lib/constants.ts
··· 214 214 export const PUBLIC_STAGING_APPVIEW_DID = 'did:web:api.staging.bsky.dev' 215 215 216 216 export const DEV_ENV_APPVIEW = `http://localhost:2584` // always the same 217 + export const DEV_ENV_APPVIEW_DID = `did:plc:dw4kbjf5mn7nhenabiqpkyh3` // always the same 217 218 218 219 // temp hack for e2e - esb 219 220 export const BLUESKY_PROXY_HEADER = {
+2 -2
src/lib/currency.ts
··· 1 1 import React from 'react' 2 2 3 3 import {deviceLocales} from '#/locale/deviceLocales' 4 - import {useGeolocationStatus} from '#/state/geolocation' 5 4 import {useLanguagePrefs} from '#/state/preferences' 5 + import {useGeolocation} from '#/geolocation' 6 6 7 7 /** 8 8 * From react-native-localize ··· 275 275 export function useFormatCurrency( 276 276 options?: Parameters<typeof Intl.NumberFormat>[1], 277 277 ) { 278 - const {location: geolocation} = useGeolocationStatus() 278 + const geolocation = useGeolocation() 279 279 const {appLanguage} = useLanguagePrefs() 280 280 return React.useMemo(() => { 281 281 const locale = deviceLocales.at(0)
+1 -1
src/lib/hooks/useAccountSwitcher.ts
··· 36 36 // So we change the URL ourselves. The navigator will pick it up on remount. 37 37 history.pushState(null, '', '/') 38 38 } 39 - await resumeSession(account) 39 + await resumeSession(account, true) 40 40 logEvent('account:loggedIn', {logContext, withPassword: false}) 41 41 Toast.show(_(msg`Signed in as @${account.handle}`)) 42 42 } else {
+1
src/lib/hooks/useCreateSupportLink.ts
··· 9 9 10 10 export enum SupportCode { 11 11 AA_DID = 'AA_DID', 12 + AA_BIRTHDATE = 'AA_BIRTHDATE', 12 13 } 13 14 14 15 /**
+4 -35
src/lib/hooks/useIntentHandler.ts
··· 4 4 import * as WebBrowser from 'expo-web-browser' 5 5 6 6 import {useOpenComposer} from '#/lib/hooks/useOpenComposer' 7 + import {parseLinkingUrl} from '#/lib/parseLinkingUrl' 7 8 import {logger} from '#/logger' 8 9 import {isIOS, isNative} from '#/platform/detection' 9 10 import {useSession} from '#/state/session' 10 11 import {useCloseAllActiveElements} from '#/state/util' 11 - import { 12 - parseAgeAssuranceRedirectDialogState, 13 - useAgeAssuranceRedirectDialogControl, 14 - } from '#/components/ageAssurance/AgeAssuranceRedirectDialog' 15 12 import {useIntentDialogs} from '#/components/intents/IntentDialogs' 16 13 import {Referrer} from '../../../modules/expo-bluesky-swiss-army' 17 14 import {useApplyPullRequestOTAUpdate} from './useOTAUpdates' ··· 27 24 const incomingUrl = Linking.useLinkingURL() 28 25 const composeIntent = useComposeIntent() 29 26 const verifyEmailIntent = useVerifyEmailIntent() 30 - const ageAssuranceRedirectDialogControl = 31 - useAgeAssuranceRedirectDialogControl() 32 27 const {currentAccount} = useSession() 33 28 const {tryApplyUpdate} = useApplyPullRequestOTAUpdate() 34 29 ··· 47 42 hostname: referrerInfo?.hostname, 48 43 }) 49 44 } 50 - 51 - // We want to be able to support bluesky:// deeplinks. It's unnatural for someone to use a deeplink with three 52 - // slashes, like bluesky:///intent/follow. However, supporting just two slashes causes us to have to take care 53 - // of two cases when parsing the url. If we ensure there is a third slash, we can always ensure the first 54 - // path parameter is in pathname rather than in hostname. 55 - if (url.startsWith('bluesky://') && !url.startsWith('bluesky:///')) { 56 - url = url.replace('bluesky://', 'bluesky:///') 57 - } 58 - 59 - const urlp = new URL(url) 60 - const [__, intent, intentType] = urlp.pathname.split('/') 45 + const urlp = parseLinkingUrl(url) 46 + const [, intent, intentType] = urlp.pathname.split('/') 61 47 62 48 // On native, our links look like bluesky://intent/SomeIntent, so we have to check the hostname for the 63 49 // intent check. On web, we have to check the first part of the path since we have an actual hostname ··· 82 68 return 83 69 } 84 70 case 'age-assurance': { 85 - const state = parseAgeAssuranceRedirectDialogState({ 86 - result: params.get('result') ?? undefined, 87 - actorDid: params.get('actorDid') ?? undefined, 88 - }) 89 - 90 - /* 91 - * If we don't have an account or the account doesn't match, do 92 - * nothing. By the time the user switches to their other account, AA 93 - * state should be ready for them. 94 - */ 95 - if ( 96 - state && 97 - currentAccount && 98 - state.actorDid === currentAccount.did 99 - ) { 100 - ageAssuranceRedirectDialogControl.open(state) 101 - } 71 + // Handled in `#/ageAssurance/components/RedirectOverlay.tsx` 102 72 return 103 73 } 104 74 case 'apply-ota': { ··· 127 97 incomingUrl, 128 98 composeIntent, 129 99 verifyEmailIntent, 130 - ageAssuranceRedirectDialogControl, 131 100 currentAccount, 132 101 tryApplyUpdate, 133 102 ])
+12 -15
src/lib/notifications/notifications.ts
··· 9 9 import {logger as notyLogger} from '#/lib/notifications/util' 10 10 import {isNetworkError} from '#/lib/strings/errors' 11 11 import {isNative} from '#/platform/detection' 12 - import {useAgeAssuranceContext} from '#/state/ageAssurance' 13 12 import {type SessionAccount, useAgent, useSession} from '#/state/session' 14 13 import BackgroundNotificationHandler from '#/../modules/expo-background-notification-handler' 14 + import {useAgeAssurance} from '#/ageAssurance' 15 15 import {IS_DEV} from '#/env' 16 16 17 17 /** ··· 125 125 * @see https://github.com/bluesky-social/social-app/pull/4467 126 126 */ 127 127 export function useGetAndRegisterPushToken() { 128 - const {isAgeRestricted} = useAgeAssuranceContext() 128 + const aa = useAgeAssurance() 129 129 const registerPushToken = useRegisterPushToken() 130 130 return useCallback( 131 131 async ({ ··· 152 152 */ 153 153 registerPushToken({ 154 154 token, 155 - isAgeRestricted: isAgeRestrictedOverride ?? isAgeRestricted, 155 + isAgeRestricted: 156 + isAgeRestrictedOverride ?? aa.state.access !== aa.Access.Full, 156 157 }) 157 158 } 158 159 159 160 return token 160 161 }, 161 - [registerPushToken, isAgeRestricted], 162 + [registerPushToken, aa], 162 163 ) 163 164 } 164 165 ··· 173 174 const {currentAccount} = useSession() 174 175 const registerPushToken = useRegisterPushToken() 175 176 const getAndRegisterPushToken = useGetAndRegisterPushToken() 176 - const {isReady: isAgeRestrictionReady, isAgeRestricted} = 177 - useAgeAssuranceContext() 177 + const aa = useAgeAssurance() 178 178 179 179 useEffect(() => { 180 180 /** 181 181 * We want this to init right away _after_ we have a logged in user, and 182 182 * _after_ we've loaded their age assurance state. 183 183 */ 184 - if (!currentAccount || !isAgeRestrictionReady) return 184 + if (!currentAccount) return 185 185 186 186 notyLogger.debug(`useNotificationsRegistration`) 187 187 ··· 206 206 * @see https://docs.expo.dev/versions/latest/sdk/notifications/#addpushtokenlistenerlistener 207 207 */ 208 208 const subscription = Notifications.addPushTokenListener(async token => { 209 - registerPushToken({token, isAgeRestricted: isAgeRestricted}) 209 + registerPushToken({ 210 + token, 211 + isAgeRestricted: aa.state.access !== aa.Access.Full, 212 + }) 210 213 notyLogger.debug(`addPushTokenListener callback`, {token}) 211 214 }) 212 215 213 216 return () => { 214 217 subscription.remove() 215 218 } 216 - }, [ 217 - currentAccount, 218 - getAndRegisterPushToken, 219 - registerPushToken, 220 - isAgeRestrictionReady, 221 - isAgeRestricted, 222 - ]) 219 + }, [currentAccount, getAndRegisterPushToken, registerPushToken, aa]) 223 220 } 224 221 225 222 export function useRequestNotificationsPermission() {
+10
src/lib/parseLinkingUrl.ts
··· 1 + export function parseLinkingUrl(url: string): URL { 2 + /* 3 + * Hack: add a third slash to bluesky:// urls so that `URL.host` is empty and 4 + * `URL.pathname` has the full path. 5 + */ 6 + if (url.startsWith('bluesky://') && !url.startsWith('bluesky:///')) { 7 + url = url.replace('bluesky://', 'bluesky:///') 8 + } 9 + return new URL(url) 10 + }
+2 -1
src/lib/strings/url-helpers.ts
··· 41 41 collection: string, 42 42 rkey: string, 43 43 ) { 44 - const urip = new AtUri('at://host/') 44 + const urip = new AtUri('at://placeholder.placeholder/') 45 + // @ts-expect-error TODO new-sdk-migration 45 46 urip.host = didOrName 46 47 urip.collection = collection 47 48 urip.rkey = rkey
+1
src/logger/metrics.ts
··· 22 22 | 'SignupQueued' 23 23 | 'Deactivated' 24 24 | 'Takendown' 25 + | 'AgeAssuranceNoAccessScreen' 25 26 scope: 'current' | 'every' 26 27 } 27 28 'notifications:openApp': {
+1 -1
src/screens/Login/ChooseAccountForm.tsx
··· 45 45 } 46 46 try { 47 47 setPendingDid(account.did) 48 - await resumeSession(account) 48 + await resumeSession(account, true) 49 49 logEvent('account:loggedIn', { 50 50 logContext: 'ChooseAccountForm', 51 51 withPassword: false,
+93 -148
src/screens/Moderation/index.tsx
··· 12 12 } from '#/lib/routes/types' 13 13 import {logger} from '#/logger' 14 14 import {isIOS} from '#/platform/detection' 15 - import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance' 16 15 import { 17 16 useMyLabelersQuery, 18 17 usePreferencesQuery, ··· 22 21 import {isNonConfigurableModerationAuthority} from '#/state/session/additional-moderation-authorities' 23 22 import {useSetMinimalShellMode} from '#/state/shell' 24 23 import {atoms as a, useBreakpoints, useTheme, type ViewStyleProp} from '#/alf' 25 - import {Admonition} from '#/components/Admonition' 26 24 import {AgeAssuranceAdmonition} from '#/components/ageAssurance/AgeAssuranceAdmonition' 27 - import {Button, ButtonText} from '#/components/Button' 28 - import * as Dialog from '#/components/Dialog' 29 - import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings' 25 + import {Button} from '#/components/Button' 30 26 import {useGlobalDialogsControlContext} from '#/components/dialogs/Context' 31 27 import {Divider} from '#/components/Divider' 32 28 import * as Toggle from '#/components/forms/Toggle' ··· 45 41 import {Loader} from '#/components/Loader' 46 42 import {GlobalLabelPreference} from '#/components/moderation/LabelPreference' 47 43 import {Text} from '#/components/Typography' 44 + import {useAgeAssurance} from '#/ageAssurance' 48 45 49 46 function ErrorState({error}: {error: string}) { 50 47 const t = useTheme() ··· 86 83 error: preferencesError, 87 84 data: preferences, 88 85 } = usePreferencesQuery() 89 - const {isReady: isAgeInfoReady} = useAgeAssurance() 90 86 91 - const isLoading = isPreferencesLoading || !isAgeInfoReady 87 + const isLoading = isPreferencesLoading 92 88 const error = preferencesError 93 89 94 90 return ( ··· 162 158 const setMinimalShellMode = useSetMinimalShellMode() 163 159 const {gtMobile} = useBreakpoints() 164 160 const {mutedWordsDialogControl} = useGlobalDialogsControlContext() 165 - const birthdateDialogControl = Dialog.useDialogControl() 166 161 const { 167 162 isLoading: isLabelersLoading, 168 163 data: labelers, 169 164 error: labelersError, 170 165 } = useMyLabelersQuery() 171 - const {declaredAge, isDeclaredUnderage, isAgeRestricted} = useAgeAssurance() 166 + const aa = useAgeAssurance() 172 167 173 168 useFocusEffect( 174 169 useCallback(() => { ··· 202 197 203 198 return ( 204 199 <View style={[a.pt_2xl, a.px_lg, gtMobile && a.px_2xl]}> 205 - {isDeclaredUnderage && ( 206 - <View style={[a.pb_2xl]}> 207 - <Admonition type="tip" style={[a.pb_md]}> 208 - <Trans> 209 - Your declared age is under 18. Some settings below may be 210 - disabled. If this was a mistake, you may edit your birthdate in 211 - your{' '} 212 - <InlineLinkText 213 - to="/settings/account" 214 - label={_(msg`Go to account settings`)}> 215 - account settings 216 - </InlineLinkText> 217 - . 218 - </Trans> 219 - </Admonition> 220 - </View> 221 - )} 222 - 223 200 <Text 224 201 style={[ 225 202 a.text_md, ··· 328 305 </Link> 329 306 </View> 330 307 331 - {(!isDeclaredUnderage || declaredAge === undefined) && ( 332 - <Text 308 + <Text 309 + style={[ 310 + a.pt_2xl, 311 + a.pb_md, 312 + a.text_md, 313 + a.font_semi_bold, 314 + t.atoms.text_contrast_high, 315 + ]}> 316 + <Trans>Content filters</Trans> 317 + </Text> 318 + 319 + <AgeAssuranceAdmonition style={[a.pb_md]}> 320 + <Trans> 321 + You must complete age assurance in order to access content filters. 322 + </Trans> 323 + </AgeAssuranceAdmonition> 324 + 325 + <View style={[a.gap_md]}> 326 + <View 333 327 style={[ 334 - a.pt_2xl, 335 - a.pb_md, 336 - a.text_md, 337 - a.font_semi_bold, 338 - t.atoms.text_contrast_high, 328 + a.w_full, 329 + a.rounded_md, 330 + a.overflow_hidden, 331 + t.atoms.bg_contrast_25, 339 332 ]}> 340 - <Trans>Content filters</Trans> 341 - </Text> 342 - )} 343 - 344 - {declaredAge === undefined ? ( 345 - <> 346 - <Button 347 - label={_(msg`Confirm your birthdate`)} 348 - size="small" 349 - variant="solid" 350 - color="secondary" 351 - onPress={() => { 352 - birthdateDialogControl.open() 353 - }} 354 - style={[a.justify_between, a.rounded_md, a.px_lg, a.py_lg]}> 355 - <ButtonText> 356 - <Trans>Confirm your age:</Trans> 357 - </ButtonText> 358 - <ButtonText> 359 - <Trans>Set birthdate</Trans> 360 - </ButtonText> 361 - </Button> 362 - 363 - <BirthDateSettingsDialog control={birthdateDialogControl} /> 364 - </> 365 - ) : !isDeclaredUnderage ? ( 366 - <> 367 - <AgeAssuranceAdmonition style={[a.pb_md]}> 368 - <Trans> 369 - You must complete age assurance in order to access the settings 370 - below. 371 - </Trans> 372 - </AgeAssuranceAdmonition> 373 - 374 - <View style={[a.gap_md]}> 375 - <View 376 - style={[ 377 - a.w_full, 378 - a.rounded_md, 379 - a.overflow_hidden, 380 - t.atoms.bg_contrast_25, 381 - ]}> 382 - {!isDeclaredUnderage && ( 383 - <> 384 - <View 385 - style={[ 386 - a.py_lg, 387 - a.px_lg, 388 - a.flex_row, 389 - a.align_center, 390 - a.justify_between, 391 - disabledOnIOS && {opacity: 0.5}, 392 - ]}> 393 - <Text 394 - style={[a.font_semi_bold, t.atoms.text_contrast_high]}> 395 - <Trans>Enable adult content</Trans> 333 + {aa.state.access === aa.Access.Full && ( 334 + <> 335 + <View 336 + style={[ 337 + a.py_lg, 338 + a.px_lg, 339 + a.flex_row, 340 + a.align_center, 341 + a.justify_between, 342 + disabledOnIOS && {opacity: 0.5}, 343 + ]}> 344 + <Text style={[a.font_semi_bold, t.atoms.text_contrast_high]}> 345 + <Trans>Enable adult content</Trans> 346 + </Text> 347 + <Toggle.Item 348 + label={_(msg`Toggle to enable or disable adult content`)} 349 + disabled={disabledOnIOS} 350 + name="adultContent" 351 + value={adultContentEnabled} 352 + onChange={onToggleAdultContentEnabled}> 353 + <View style={[a.flex_row, a.align_center, a.gap_sm]}> 354 + <Text style={[t.atoms.text_contrast_medium]}> 355 + {adultContentEnabled ? ( 356 + <Trans>Enabled</Trans> 357 + ) : ( 358 + <Trans>Disabled</Trans> 359 + )} 396 360 </Text> 397 - <Toggle.Item 398 - label={_(msg`Toggle to enable or disable adult content`)} 399 - disabled={disabledOnIOS || isAgeRestricted} 400 - name="adultContent" 401 - value={adultContentEnabled} 402 - onChange={onToggleAdultContentEnabled}> 403 - <View style={[a.flex_row, a.align_center, a.gap_sm]}> 404 - <Text style={[t.atoms.text_contrast_medium]}> 405 - {adultContentEnabled ? ( 406 - <Trans>Enabled</Trans> 407 - ) : ( 408 - <Trans>Disabled</Trans> 409 - )} 410 - </Text> 411 - <Toggle.Switch /> 412 - </View> 413 - </Toggle.Item> 361 + <Toggle.Switch /> 414 362 </View> 415 - {disabledOnIOS && ( 416 - <View style={[a.pb_lg, a.px_lg]}> 417 - <Text> 418 - <Trans> 419 - Adult content can only be enabled via the Web at{' '} 420 - <InlineLinkText 421 - label={_(msg`The Bluesky web application`)} 422 - to="" 423 - onPress={evt => { 424 - evt.preventDefault() 425 - Linking.openURL('https://bsky.app/') 426 - return false 427 - }}> 428 - bsky.app 429 - </InlineLinkText> 430 - . 431 - </Trans> 432 - </Text> 433 - </View> 434 - )} 363 + </Toggle.Item> 364 + </View> 365 + {disabledOnIOS && ( 366 + <View style={[a.pb_lg, a.px_lg]}> 367 + <Text> 368 + <Trans> 369 + Adult content can only be enabled via the Web at{' '} 370 + <InlineLinkText 371 + label={_(msg`The Bluesky web application`)} 372 + to="" 373 + onPress={evt => { 374 + evt.preventDefault() 375 + Linking.openURL('https://bsky.app/') 376 + return false 377 + }}> 378 + bsky.app 379 + </InlineLinkText> 380 + . 381 + </Trans> 382 + </Text> 383 + </View> 384 + )} 435 385 436 - {adultContentEnabled && ( 437 - <> 438 - <Divider /> 439 - <GlobalLabelPreference labelDefinition={LABELS.porn} /> 440 - <Divider /> 441 - <GlobalLabelPreference labelDefinition={LABELS.sexual} /> 442 - <Divider /> 443 - <GlobalLabelPreference 444 - labelDefinition={LABELS['graphic-media']} 445 - /> 446 - <Divider /> 447 - <GlobalLabelPreference 448 - disabled={isDeclaredUnderage || isAgeRestricted} 449 - labelDefinition={LABELS.nudity} 450 - /> 451 - </> 452 - )} 386 + {adultContentEnabled && ( 387 + <> 388 + <Divider /> 389 + <GlobalLabelPreference labelDefinition={LABELS.porn} /> 390 + <Divider /> 391 + <GlobalLabelPreference labelDefinition={LABELS.sexual} /> 392 + <Divider /> 393 + <GlobalLabelPreference 394 + labelDefinition={LABELS['graphic-media']} 395 + /> 396 + <Divider /> 397 + <GlobalLabelPreference labelDefinition={LABELS.nudity} /> 453 398 </> 454 399 )} 455 - </View> 456 - </View> 457 - </> 458 - ) : null} 400 + </> 401 + )} 402 + </View> 403 + </View> 459 404 460 405 <Text 461 406 style={[
-1
src/screens/Onboarding/StepSuggestedAccounts/index.tsx
··· 312 312 return ( 313 313 <View 314 314 style={[ 315 - a.flex_1, 316 315 a.w_full, 317 316 a.py_lg, 318 317 a.px_xl,
+1
src/state/__mocks__/birthdate.ts
··· 1 + export const snoozeBirthdateUpdateAllowedForDid = () => {}
-11
src/state/ageAssurance/const.ts
··· 1 - import {type ModerationPrefs} from '@atproto/api' 2 - 3 - import {DEFAULT_LOGGED_OUT_LABEL_PREFERENCES} from '#/state/queries/preferences/moderation' 4 - 5 - export const makeAgeRestrictedModerationPrefs = ( 6 - prefs: ModerationPrefs, 7 - ): ModerationPrefs => ({ 8 - ...prefs, 9 - adultContentEnabled: false, 10 - labels: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES, 11 - })
-156
src/state/ageAssurance/index.tsx
··· 1 - import {createContext, useContext, useMemo, useState} from 'react' 2 - import {type AppBskyUnspeccedDefs} from '@atproto/api' 3 - import {useQuery} from '@tanstack/react-query' 4 - 5 - import {networkRetry} from '#/lib/async/retry' 6 - import {useGetAndRegisterPushToken} from '#/lib/notifications/notifications' 7 - import {isNetworkError} from '#/lib/strings/errors' 8 - import { 9 - type AgeAssuranceAPIContextType, 10 - type AgeAssuranceContextType, 11 - } from '#/state/ageAssurance/types' 12 - import {useIsAgeAssuranceEnabled} from '#/state/ageAssurance/useIsAgeAssuranceEnabled' 13 - import {logger} from '#/state/ageAssurance/util' 14 - import {useGeolocationStatus} from '#/state/geolocation' 15 - import {useAgent} from '#/state/session' 16 - 17 - export const createAgeAssuranceQueryKey = (did: string) => 18 - ['ageAssurance', did] as const 19 - 20 - const DEFAULT_AGE_ASSURANCE_STATE: AppBskyUnspeccedDefs.AgeAssuranceState = { 21 - lastInitiatedAt: undefined, 22 - status: 'unknown', 23 - } 24 - 25 - const AgeAssuranceContext = createContext<AgeAssuranceContextType>({ 26 - status: 'unknown', 27 - isReady: false, 28 - lastInitiatedAt: undefined, 29 - isAgeRestricted: false, 30 - }) 31 - AgeAssuranceContext.displayName = 'AgeAssuranceContext' 32 - 33 - const AgeAssuranceAPIContext = createContext<AgeAssuranceAPIContextType>({ 34 - // @ts-ignore can't be bothered to type this 35 - refetch: () => Promise.resolve(), 36 - }) 37 - AgeAssuranceAPIContext.displayName = 'AgeAssuranceAPIContext' 38 - 39 - /** 40 - * Low-level provider for fetching age assurance state on app load. Do not add 41 - * any other data fetching in here to avoid complications and reduced 42 - * performance. 43 - */ 44 - export function Provider({children}: {children: React.ReactNode}) { 45 - const agent = useAgent() 46 - const {status: geolocation} = useGeolocationStatus() 47 - const isAgeAssuranceEnabled = useIsAgeAssuranceEnabled() 48 - const getAndRegisterPushToken = useGetAndRegisterPushToken() 49 - const [refetchWhilePending, setRefetchWhilePending] = useState(false) 50 - 51 - const {data, isFetched, refetch} = useQuery({ 52 - /** 53 - * This is load bearing. We always want this query to run and end in a 54 - * "fetched" state, even if we fall back to defaults. This lets the rest of 55 - * the app know that we've at least attempted to load the AA state. 56 - * 57 - * However, it only needs to run if AA is enabled. 58 - */ 59 - enabled: isAgeAssuranceEnabled, 60 - refetchOnWindowFocus: refetchWhilePending, 61 - queryKey: createAgeAssuranceQueryKey(agent.session?.did ?? 'never'), 62 - async queryFn() { 63 - if (!agent.session) return null 64 - 65 - try { 66 - const {data} = await networkRetry(3, () => 67 - agent.app.bsky.unspecced.getAgeAssuranceState(), 68 - ) 69 - // const {data} = { 70 - // data: { 71 - // lastInitiatedAt: new Date().toISOString(), 72 - // status: 'pending', 73 - // } as AppBskyUnspeccedDefs.AgeAssuranceState, 74 - // } 75 - 76 - logger.debug(`fetch`, { 77 - data, 78 - account: agent.session?.did, 79 - }) 80 - 81 - await getAndRegisterPushToken({ 82 - isAgeRestricted: 83 - !!geolocation?.isAgeRestrictedGeo && data.status !== 'assured', 84 - }) 85 - 86 - return data 87 - } catch (e) { 88 - if (!isNetworkError(e)) { 89 - logger.error(`ageAssurance: failed to fetch`, {safeMessage: e}) 90 - } 91 - // don't re-throw error, we'll just fall back to defaults 92 - return null 93 - } 94 - }, 95 - }) 96 - 97 - /** 98 - * Derive state, or fall back to defaults 99 - */ 100 - const ageAssuranceContext = useMemo<AgeAssuranceContextType>(() => { 101 - const {status, lastInitiatedAt} = data || DEFAULT_AGE_ASSURANCE_STATE 102 - const ctx: AgeAssuranceContextType = { 103 - isReady: isFetched || !isAgeAssuranceEnabled, 104 - status, 105 - lastInitiatedAt, 106 - isAgeRestricted: isAgeAssuranceEnabled ? status !== 'assured' : false, 107 - } 108 - logger.debug(`context`, ctx) 109 - return ctx 110 - }, [isFetched, data, isAgeAssuranceEnabled]) 111 - 112 - if ( 113 - !!ageAssuranceContext.lastInitiatedAt && 114 - ageAssuranceContext.status === 'pending' && 115 - !refetchWhilePending 116 - ) { 117 - /* 118 - * If we have a pending state, we want to refetch on window focus to ensure 119 - * that we get the latest state when the user returns to the app. 120 - */ 121 - setRefetchWhilePending(true) 122 - } else if ( 123 - !!ageAssuranceContext.lastInitiatedAt && 124 - ageAssuranceContext.status !== 'pending' && 125 - refetchWhilePending 126 - ) { 127 - setRefetchWhilePending(false) 128 - } 129 - 130 - const ageAssuranceAPIContext = useMemo<AgeAssuranceAPIContextType>( 131 - () => ({ 132 - refetch, 133 - }), 134 - [refetch], 135 - ) 136 - 137 - return ( 138 - <AgeAssuranceAPIContext.Provider value={ageAssuranceAPIContext}> 139 - <AgeAssuranceContext.Provider value={ageAssuranceContext}> 140 - {children} 141 - </AgeAssuranceContext.Provider> 142 - </AgeAssuranceAPIContext.Provider> 143 - ) 144 - } 145 - 146 - /** 147 - * Access to low-level AA state. Prefer using {@link useAgeInfo} for a 148 - * more user-friendly interface. 149 - */ 150 - export function useAgeAssuranceContext() { 151 - return useContext(AgeAssuranceContext) 152 - } 153 - 154 - export function useAgeAssuranceAPIContext() { 155 - return useContext(AgeAssuranceAPIContext) 156 - }
-33
src/state/ageAssurance/types.ts
··· 1 - import {type AppBskyUnspeccedDefs} from '@atproto/api' 2 - import {type QueryObserverBaseResult} from '@tanstack/react-query' 3 - 4 - export type AgeAssuranceContextType = { 5 - /** 6 - * Whether the age assurance state has been fetched from the server. If user 7 - * is not in a region that requires AA, or AA is otherwise disabled, this 8 - * will always be `true`. 9 - */ 10 - isReady: boolean 11 - /** 12 - * The server-reported status of the user's age verification process. 13 - */ 14 - status: AppBskyUnspeccedDefs.AgeAssuranceState['status'] 15 - /** 16 - * The last time the age assurance state was attempted by the user. 17 - */ 18 - lastInitiatedAt: AppBskyUnspeccedDefs.AgeAssuranceState['lastInitiatedAt'] 19 - /** 20 - * Indicates the user is age restricted based on the requirements of their 21 - * region, and their server-provided age assurance status. Does not factor in 22 - * the user's declared age. If AA is otherise disabled, this will always be 23 - * `false`. 24 - */ 25 - isAgeRestricted: boolean 26 - } 27 - 28 - export type AgeAssuranceAPIContextType = { 29 - /** 30 - * Refreshes the age assurance state by fetching it from the server. 31 - */ 32 - refetch: QueryObserverBaseResult['refetch'] 33 - }
-44
src/state/ageAssurance/useAgeAssurance.ts
··· 1 - import {useMemo} from 'react' 2 - 3 - import {useAgeAssuranceContext} from '#/state/ageAssurance' 4 - import {logger} from '#/state/ageAssurance/util' 5 - import {usePreferencesQuery} from '#/state/queries/preferences' 6 - 7 - type AgeAssurance = ReturnType<typeof useAgeAssuranceContext> & { 8 - /** 9 - * The age the user has declared in their preferences, if any. 10 - */ 11 - declaredAge: number | undefined 12 - /** 13 - * Indicates whether the user has declared an age under 18. 14 - */ 15 - isDeclaredUnderage: boolean 16 - } 17 - 18 - /** 19 - * Computed age information based on age assurance status and the user's 20 - * declared age. Use this instead of {@link useAgeAssuranceContext} to get a 21 - * more user-friendly interface. 22 - */ 23 - export function useAgeAssurance(): AgeAssurance { 24 - const aa = useAgeAssuranceContext() 25 - const {isFetched: preferencesLoaded, data: preferences} = 26 - usePreferencesQuery() 27 - const declaredAge = preferences?.userAge 28 - 29 - return useMemo(() => { 30 - const isReady = aa.isReady && preferencesLoaded 31 - const isDeclaredUnderage = 32 - declaredAge !== undefined ? declaredAge < 18 : false 33 - const state: AgeAssurance = { 34 - isReady, 35 - status: aa.status, 36 - lastInitiatedAt: aa.lastInitiatedAt, 37 - isAgeRestricted: aa.isAgeRestricted, 38 - declaredAge, 39 - isDeclaredUnderage, 40 - } 41 - logger.debug(`state`, state) 42 - return state 43 - }, [aa, preferencesLoaded, declaredAge]) 44 - }
-102
src/state/ageAssurance/useInitAgeAssurance.ts
··· 1 - import { 2 - type AppBskyUnspeccedDefs, 3 - type AppBskyUnspeccedInitAgeAssurance, 4 - AtpAgent, 5 - } from '@atproto/api' 6 - import {useMutation, useQueryClient} from '@tanstack/react-query' 7 - 8 - import {wait} from '#/lib/async/wait' 9 - import { 10 - // DEV_ENV_APPVIEW, 11 - PUBLIC_APPVIEW, 12 - PUBLIC_APPVIEW_DID, 13 - } from '#/lib/constants' 14 - import {isNetworkError} from '#/lib/hooks/useCleanError' 15 - import {logger} from '#/logger' 16 - import {createAgeAssuranceQueryKey} from '#/state/ageAssurance' 17 - import {type DeviceLocation, useGeolocationStatus} from '#/state/geolocation' 18 - import {useAgent} from '#/state/session' 19 - 20 - let APPVIEW = PUBLIC_APPVIEW 21 - let APPVIEW_DID = PUBLIC_APPVIEW_DID 22 - 23 - /* 24 - * Uncomment if using the local dev-env 25 - */ 26 - // if (__DEV__) { 27 - // APPVIEW = DEV_ENV_APPVIEW 28 - // /* 29 - // * IMPORTANT: you need to get this value from `http://localhost:2581` 30 - // * introspection endpoint and updated in `constants`, since it changes 31 - // * every time you run the dev-env. 32 - // */ 33 - // APPVIEW_DID = `` 34 - // } 35 - 36 - /** 37 - * Creates an ISO country code string from the given geolocation data. 38 - * Examples: `GB` or `GB-ENG` 39 - */ 40 - function createISOCountryCode( 41 - geolocation: Omit<DeviceLocation, 'countryCode'> & { 42 - countryCode: string 43 - }, 44 - ): string { 45 - return geolocation.countryCode.toUpperCase() 46 - } 47 - 48 - export function useInitAgeAssurance() { 49 - const qc = useQueryClient() 50 - const agent = useAgent() 51 - const {status: geolocation} = useGeolocationStatus() 52 - return useMutation({ 53 - async mutationFn( 54 - props: Omit<AppBskyUnspeccedInitAgeAssurance.InputSchema, 'countryCode'>, 55 - ) { 56 - const countryCode = geolocation?.countryCode 57 - const regionCode = geolocation?.regionCode 58 - if (!countryCode) { 59 - throw new Error(`Geolocation not available, cannot init age assurance.`) 60 - } 61 - 62 - const { 63 - data: {token}, 64 - } = await agent.com.atproto.server.getServiceAuth({ 65 - aud: APPVIEW_DID, 66 - lxm: `app.bsky.unspecced.initAgeAssurance`, 67 - }) 68 - 69 - const appView = new AtpAgent({service: APPVIEW}) 70 - appView.sessionManager.session = {...agent.session!} 71 - appView.sessionManager.session.accessJwt = token 72 - appView.sessionManager.session.refreshJwt = '' 73 - 74 - /* 75 - * 2s wait is good actually. Email sending takes a hot sec and this helps 76 - * ensure the email is ready for the user once they open their inbox. 77 - */ 78 - const {data} = await wait( 79 - 2e3, 80 - appView.app.bsky.unspecced.initAgeAssurance({ 81 - ...props, 82 - countryCode: createISOCountryCode({ 83 - countryCode, 84 - regionCode, 85 - }), 86 - }), 87 - ) 88 - 89 - qc.setQueryData<AppBskyUnspeccedDefs.AgeAssuranceState>( 90 - createAgeAssuranceQueryKey(agent.session?.did ?? 'never'), 91 - () => data, 92 - ) 93 - }, 94 - onError(e) { 95 - if (!isNetworkError(e)) { 96 - logger.error(`useInitAgeAssurance failed`, { 97 - safeMessage: e, 98 - }) 99 - } 100 - }, 101 - }) 102 - }
-11
src/state/ageAssurance/useIsAgeAssuranceEnabled.ts
··· 1 - import {useMemo} from 'react' 2 - 3 - import {useGeolocationStatus} from '#/state/geolocation' 4 - 5 - export function useIsAgeAssuranceEnabled() { 6 - const {status: geolocation} = useGeolocationStatus() 7 - 8 - return useMemo(() => { 9 - return !!geolocation?.isAgeRestrictedGeo 10 - }, [geolocation]) 11 - }
src/state/ageAssurance/util.ts src/ageAssurance/logger.ts
+64
src/state/birthdate.ts
··· 1 + import {useMemo} from 'react' 2 + import {useMutation, useQueryClient} from '@tanstack/react-query' 3 + 4 + import {preferencesQueryKey} from '#/state/queries/preferences' 5 + import {useAgent, useSession} from '#/state/session' 6 + import {usePatchAgeAssuranceOtherRequiredData} from '#/ageAssurance' 7 + import {IS_DEV} from '#/env' 8 + import {account} from '#/storage' 9 + 10 + // 6s in dev, 48h in prod 11 + const BIRTHDATE_DELAY_HOURS = IS_DEV ? 0.001 : 48 12 + 13 + /** 14 + * Stores the timestamp of the birthday update locally. This is used to 15 + * debounce birthday updates globally. 16 + * 17 + * Use {@link useIsBirthDateUpdateAllowed} to check if an update is allowed. 18 + */ 19 + export function snoozeBirthdateUpdateAllowedForDid(did: string) { 20 + account.set([did, 'birthdateLastUpdatedAt'], new Date().toISOString()) 21 + } 22 + 23 + /** 24 + * Returns whether a birthdate update is currently allowed, based on the 25 + * last update timestamp stored locally. 26 + */ 27 + export function useIsBirthdateUpdateAllowed() { 28 + const {currentAccount} = useSession() 29 + return useMemo(() => { 30 + if (!currentAccount) return false 31 + const lastUpdated = account.get([ 32 + currentAccount.did, 33 + 'birthdateLastUpdatedAt', 34 + ]) 35 + if (!lastUpdated) return true 36 + const lastUpdatedDate = new Date(lastUpdated) 37 + const diffMs = Date.now() - lastUpdatedDate.getTime() 38 + const diffHours = diffMs / (1000 * 60 * 60) 39 + return diffHours >= BIRTHDATE_DELAY_HOURS 40 + }, [currentAccount]) 41 + } 42 + 43 + export function useBirthdateMutation() { 44 + const queryClient = useQueryClient() 45 + const agent = useAgent() 46 + const patchOtherRequiredData = usePatchAgeAssuranceOtherRequiredData() 47 + 48 + return useMutation<void, unknown, {birthDate: Date}>({ 49 + mutationFn: async ({birthDate}: {birthDate: Date}) => { 50 + const bday = birthDate.toISOString() 51 + await agent.setPersonalDetails({birthDate: bday}) 52 + // triggers a refetch 53 + await queryClient.invalidateQueries({ 54 + queryKey: preferencesQueryKey, 55 + }) 56 + /** 57 + * Also patch the age assurance other required data with the new 58 + * birthdate, which may change the user's age assurance access level. 59 + */ 60 + patchOtherRequiredData({birthdate: bday}) 61 + snoozeBirthdateUpdateAllowedForDid(agent.sessionManager.did!) 62 + }, 63 + }) 64 + }
-141
src/state/geolocation/config.ts
··· 1 - import {networkRetry} from '#/lib/async/retry' 2 - import { 3 - DEFAULT_GEOLOCATION_CONFIG, 4 - GEOLOCATION_CONFIG_URL, 5 - } from '#/state/geolocation/const' 6 - import {emitGeolocationConfigUpdate} from '#/state/geolocation/events' 7 - import {logger} from '#/state/geolocation/logger' 8 - import {BAPP_CONFIG_DEV_BYPASS_SECRET, IS_DEV} from '#/env' 9 - import {type Device, device} from '#/storage' 10 - 11 - async function getGeolocationConfig( 12 - url: string, 13 - ): Promise<Device['geolocation']> { 14 - const res = await fetch(url, { 15 - headers: IS_DEV 16 - ? { 17 - 'x-dev-bypass-secret': BAPP_CONFIG_DEV_BYPASS_SECRET, 18 - } 19 - : undefined, 20 - }) 21 - 22 - if (!res.ok) { 23 - throw new Error(`config: fetch failed ${res.status}`) 24 - } 25 - 26 - const json = await res.json() 27 - 28 - if (json.countryCode) { 29 - /** 30 - * Only construct known values here, ignore any extras. 31 - */ 32 - const config: Device['geolocation'] = { 33 - countryCode: json.countryCode, 34 - regionCode: json.regionCode ?? undefined, 35 - ageRestrictedGeos: json.ageRestrictedGeos ?? [], 36 - ageBlockedGeos: json.ageBlockedGeos ?? [], 37 - } 38 - logger.debug(`config: success`) 39 - return config 40 - } else { 41 - return undefined 42 - } 43 - } 44 - 45 - /** 46 - * Local promise used within this file only. 47 - */ 48 - let geolocationConfigResolution: Promise<{success: boolean}> | undefined 49 - 50 - /** 51 - * Begin the process of resolving geolocation config. This should be called 52 - * once at app start. 53 - * 54 - * THIS METHOD SHOULD NEVER THROW. 55 - * 56 - * This method is otherwise not used for any purpose. To ensure geolocation 57 - * config is resolved, use {@link ensureGeolocationConfigIsResolved} 58 - */ 59 - export function beginResolveGeolocationConfig() { 60 - /** 61 - * Here for debug purposes. Uncomment to prevent hitting the remote geo service, and apply whatever data you require for testing. 62 - */ 63 - // if (__DEV__) { 64 - // geolocationConfigResolution = new Promise(y => y({success: true})) 65 - // device.set(['deviceGeolocation'], undefined) // clears GPS data 66 - // device.set(['geolocation'], DEFAULT_GEOLOCATION_CONFIG) // clears bapp-config data 67 - // return 68 - // } 69 - 70 - geolocationConfigResolution = new Promise(async resolve => { 71 - let success = true 72 - 73 - try { 74 - // Try once, fail fast 75 - const config = await getGeolocationConfig(GEOLOCATION_CONFIG_URL) 76 - if (config) { 77 - device.set(['geolocation'], config) 78 - emitGeolocationConfigUpdate(config) 79 - } else { 80 - // endpoint should throw on all failures, this is insurance 81 - throw new Error( 82 - `geolocation config: nothing returned from initial request`, 83 - ) 84 - } 85 - } catch (e: any) { 86 - success = false 87 - 88 - logger.debug(`config: failed initial request`, { 89 - safeMessage: e.message, 90 - }) 91 - 92 - // set to default 93 - device.set(['geolocation'], DEFAULT_GEOLOCATION_CONFIG) 94 - 95 - // retry 3 times, but don't await, proceed with default 96 - networkRetry(3, () => getGeolocationConfig(GEOLOCATION_CONFIG_URL)) 97 - .then(config => { 98 - if (config) { 99 - device.set(['geolocation'], config) 100 - emitGeolocationConfigUpdate(config) 101 - success = true 102 - } else { 103 - // endpoint should throw on all failures, this is insurance 104 - throw new Error(`config: nothing returned from retries`) 105 - } 106 - }) 107 - .catch((e: any) => { 108 - // complete fail closed 109 - logger.debug(`config: failed retries`, { 110 - safeMessage: e.message, 111 - }) 112 - }) 113 - } finally { 114 - resolve({success}) 115 - } 116 - }) 117 - } 118 - 119 - /** 120 - * Ensure that geolocation config has been resolved, or at the very least attempted 121 - * once. Subsequent retries will not be captured by this `await`. Those will be 122 - * reported via {@link emitGeolocationConfigUpdate}. 123 - */ 124 - export async function ensureGeolocationConfigIsResolved() { 125 - if (!geolocationConfigResolution) { 126 - throw new Error(`config: beginResolveGeolocationConfig not called yet`) 127 - } 128 - 129 - const cached = device.get(['geolocation']) 130 - if (cached) { 131 - logger.debug(`config: using cache`) 132 - } else { 133 - logger.debug(`config: no cache`) 134 - const {success} = await geolocationConfigResolution 135 - if (success) { 136 - logger.debug(`config: resolved`) 137 - } else { 138 - logger.info(`config: failed to resolve`) 139 - } 140 - } 141 - }
-30
src/state/geolocation/const.ts
··· 1 - import {type GeolocationStatus} from '#/state/geolocation/types' 2 - import {BAPP_CONFIG_DEV_URL, IS_DEV} from '#/env' 3 - import {type Device} from '#/storage' 4 - 5 - export const IPCC_URL = `https://bsky.app/ipcc` 6 - export const BAPP_CONFIG_URL_PROD = `https://ip.bsky.app/config` 7 - export const BAPP_CONFIG_URL = IS_DEV 8 - ? (BAPP_CONFIG_DEV_URL ?? BAPP_CONFIG_URL_PROD) 9 - : BAPP_CONFIG_URL_PROD 10 - export const GEOLOCATION_CONFIG_URL = BAPP_CONFIG_URL 11 - 12 - /** 13 - * Default geolocation config. 14 - */ 15 - export const DEFAULT_GEOLOCATION_CONFIG: Device['geolocation'] = { 16 - countryCode: undefined, 17 - regionCode: undefined, 18 - ageRestrictedGeos: [], 19 - ageBlockedGeos: [], 20 - } 21 - 22 - /** 23 - * Default geolocation status. 24 - */ 25 - export const DEFAULT_GEOLOCATION_STATUS: GeolocationStatus = { 26 - countryCode: undefined, 27 - regionCode: undefined, 28 - isAgeRestrictedGeo: false, 29 - isAgeBlockedGeo: false, 30 - }
-19
src/state/geolocation/events.ts
··· 1 - import EventEmitter from 'eventemitter3' 2 - 3 - import {type Device} from '#/storage' 4 - 5 - const events = new EventEmitter() 6 - const EVENT = 'geolocation-config-updated' 7 - 8 - export const emitGeolocationConfigUpdate = (config: Device['geolocation']) => { 9 - events.emit(EVENT, config) 10 - } 11 - 12 - export const onGeolocationConfigUpdate = ( 13 - listener: (config: Device['geolocation']) => void, 14 - ) => { 15 - events.on(EVENT, listener) 16 - return () => { 17 - events.off(EVENT, listener) 18 - } 19 - }
-155
src/state/geolocation/index.tsx
··· 1 - import React from 'react' 2 - 3 - import { 4 - DEFAULT_GEOLOCATION_CONFIG, 5 - DEFAULT_GEOLOCATION_STATUS, 6 - } from '#/state/geolocation/const' 7 - import {onGeolocationConfigUpdate} from '#/state/geolocation/events' 8 - import {logger} from '#/state/geolocation/logger' 9 - import { 10 - type DeviceLocation, 11 - type GeolocationStatus, 12 - } from '#/state/geolocation/types' 13 - import {useSyncedDeviceGeolocation} from '#/state/geolocation/useSyncedDeviceGeolocation' 14 - import { 15 - computeGeolocationStatus, 16 - mergeGeolocation, 17 - } from '#/state/geolocation/util' 18 - import {type Device, device} from '#/storage' 19 - 20 - export * from '#/state/geolocation/config' 21 - export * from '#/state/geolocation/types' 22 - export * from '#/state/geolocation/util' 23 - 24 - type DeviceGeolocationContext = { 25 - deviceGeolocation: DeviceLocation | undefined 26 - } 27 - 28 - type DeviceGeolocationAPIContext = { 29 - setDeviceGeolocation(deviceGeolocation: DeviceLocation): void 30 - } 31 - 32 - type GeolocationConfigContext = { 33 - config: Device['geolocation'] 34 - } 35 - 36 - type GeolocationStatusContext = { 37 - /** 38 - * Merged geolocation from config and device GPS (if available). 39 - */ 40 - location: DeviceLocation 41 - /** 42 - * Computed geolocation status based on the merged location and config. 43 - */ 44 - status: GeolocationStatus 45 - } 46 - 47 - const DeviceGeolocationContext = React.createContext<DeviceGeolocationContext>({ 48 - deviceGeolocation: undefined, 49 - }) 50 - DeviceGeolocationContext.displayName = 'DeviceGeolocationContext' 51 - 52 - const DeviceGeolocationAPIContext = 53 - React.createContext<DeviceGeolocationAPIContext>({ 54 - setDeviceGeolocation: () => {}, 55 - }) 56 - DeviceGeolocationAPIContext.displayName = 'DeviceGeolocationAPIContext' 57 - 58 - const GeolocationConfigContext = React.createContext<GeolocationConfigContext>({ 59 - config: DEFAULT_GEOLOCATION_CONFIG, 60 - }) 61 - GeolocationConfigContext.displayName = 'GeolocationConfigContext' 62 - 63 - const GeolocationStatusContext = React.createContext<GeolocationStatusContext>({ 64 - location: { 65 - countryCode: undefined, 66 - regionCode: undefined, 67 - }, 68 - status: DEFAULT_GEOLOCATION_STATUS, 69 - }) 70 - GeolocationStatusContext.displayName = 'GeolocationStatusContext' 71 - 72 - /** 73 - * Provider of geolocation config and computed geolocation status. 74 - */ 75 - export function GeolocationStatusProvider({ 76 - children, 77 - }: { 78 - children: React.ReactNode 79 - }) { 80 - const {deviceGeolocation} = React.useContext(DeviceGeolocationContext) 81 - const [config, setConfig] = React.useState(() => { 82 - const initial = device.get(['geolocation']) || DEFAULT_GEOLOCATION_CONFIG 83 - return initial 84 - }) 85 - 86 - React.useEffect(() => { 87 - return onGeolocationConfigUpdate(config => { 88 - setConfig(config!) 89 - }) 90 - }, []) 91 - 92 - const configContext = React.useMemo(() => ({config}), [config]) 93 - const statusContext = React.useMemo(() => { 94 - if (deviceGeolocation?.countryCode) { 95 - logger.debug('has device geolocation available') 96 - } 97 - const geolocation = mergeGeolocation(deviceGeolocation, config) 98 - const status = computeGeolocationStatus(geolocation, config) 99 - // ensure this remains debug and never leaves device 100 - logger.debug('result', {deviceGeolocation, geolocation, status, config}) 101 - return {location: geolocation, status} 102 - }, [config, deviceGeolocation]) 103 - 104 - return ( 105 - <GeolocationConfigContext.Provider value={configContext}> 106 - <GeolocationStatusContext.Provider value={statusContext}> 107 - {children} 108 - </GeolocationStatusContext.Provider> 109 - </GeolocationConfigContext.Provider> 110 - ) 111 - } 112 - 113 - /** 114 - * Provider of providers. Provides device geolocation data to lower-level 115 - * `GeolocationStatusProvider`, and device geolocation APIs to children. 116 - */ 117 - export function Provider({children}: {children: React.ReactNode}) { 118 - const [deviceGeolocation, setDeviceGeolocation] = useSyncedDeviceGeolocation() 119 - 120 - const handleSetDeviceGeolocation = React.useCallback( 121 - (location: DeviceLocation) => { 122 - logger.debug('setting device geolocation') 123 - setDeviceGeolocation({ 124 - countryCode: location.countryCode ?? undefined, 125 - regionCode: location.regionCode ?? undefined, 126 - }) 127 - }, 128 - [setDeviceGeolocation], 129 - ) 130 - 131 - return ( 132 - <DeviceGeolocationAPIContext.Provider 133 - value={React.useMemo( 134 - () => ({setDeviceGeolocation: handleSetDeviceGeolocation}), 135 - [handleSetDeviceGeolocation], 136 - )}> 137 - <DeviceGeolocationContext.Provider 138 - value={React.useMemo(() => ({deviceGeolocation}), [deviceGeolocation])}> 139 - <GeolocationStatusProvider>{children}</GeolocationStatusProvider> 140 - </DeviceGeolocationContext.Provider> 141 - </DeviceGeolocationAPIContext.Provider> 142 - ) 143 - } 144 - 145 - export function useDeviceGeolocationApi() { 146 - return React.useContext(DeviceGeolocationAPIContext) 147 - } 148 - 149 - export function useGeolocationConfig() { 150 - return React.useContext(GeolocationConfigContext) 151 - } 152 - 153 - export function useGeolocationStatus() { 154 - return React.useContext(GeolocationStatusContext) 155 - }
src/state/geolocation/logger.ts src/geolocation/logger.ts
-9
src/state/geolocation/types.ts
··· 1 - export type DeviceLocation = { 2 - countryCode: string | undefined 3 - regionCode: string | undefined 4 - } 5 - 6 - export type GeolocationStatus = DeviceLocation & { 7 - isAgeRestrictedGeo: boolean 8 - isAgeBlockedGeo: boolean 9 - }
-43
src/state/geolocation/useRequestDeviceLocation.ts
··· 1 - import {useCallback} from 'react' 2 - import * as Location from 'expo-location' 3 - 4 - import {type DeviceLocation} from '#/state/geolocation/types' 5 - import {getDeviceGeolocation} from '#/state/geolocation/util' 6 - 7 - export {PermissionStatus} from 'expo-location' 8 - 9 - export function useRequestDeviceLocation(): () => Promise< 10 - | { 11 - granted: true 12 - location: DeviceLocation | undefined 13 - } 14 - | { 15 - granted: false 16 - status: { 17 - canAskAgain: boolean 18 - /** 19 - * Enum, use `PermissionStatus` export for comparisons 20 - */ 21 - permissionStatus: Location.PermissionStatus 22 - } 23 - } 24 - > { 25 - return useCallback(async () => { 26 - const status = await Location.requestForegroundPermissionsAsync() 27 - 28 - if (status.granted) { 29 - return { 30 - granted: true, 31 - location: await getDeviceGeolocation(), 32 - } 33 - } else { 34 - return { 35 - granted: false, 36 - status: { 37 - canAskAgain: status.canAskAgain, 38 - permissionStatus: status.status, 39 - }, 40 - } 41 - } 42 - }, []) 43 - }
-93
src/state/geolocation/useSyncedDeviceGeolocation.ts
··· 1 - import {useEffect, useRef} from 'react' 2 - import * as Location from 'expo-location' 3 - import {createPermissionHook} from 'expo-modules-core' 4 - 5 - import {logger} from '#/state/geolocation/logger' 6 - import {getDeviceGeolocation} from '#/state/geolocation/util' 7 - import {device, useStorage} from '#/storage' 8 - 9 - /** 10 - * Location.useForegroundPermissions on web just errors if the navigator.permissions API is not available. 11 - * We need to catch and ignore it, since it's effectively denied. 12 - * @see https://github.com/expo/expo/blob/72f1562ed9cce5ff6dfe04aa415b71632a3d4b87/packages/expo-location/src/Location.ts#L290-L293 13 - */ 14 - const useForegroundPermissions = createPermissionHook({ 15 - getMethod: () => 16 - Location.getForegroundPermissionsAsync().catch(error => { 17 - logger.debug( 18 - 'useForegroundPermission: error getting location permissions', 19 - {safeMessage: error}, 20 - ) 21 - return { 22 - status: Location.PermissionStatus.DENIED, 23 - granted: false, 24 - canAskAgain: false, 25 - expires: 0, 26 - } 27 - }), 28 - requestMethod: () => 29 - Location.requestForegroundPermissionsAsync().catch(error => { 30 - logger.debug( 31 - 'useForegroundPermission: error requesting location permissions', 32 - {safeMessage: error}, 33 - ) 34 - return { 35 - status: Location.PermissionStatus.DENIED, 36 - granted: false, 37 - canAskAgain: false, 38 - expires: 0, 39 - } 40 - }), 41 - }) 42 - 43 - /** 44 - * Hook to get and sync the device geolocation from the device GPS and store it 45 - * using device storage. If permissions are not granted, it will clear any cached 46 - * storage value. 47 - */ 48 - export function useSyncedDeviceGeolocation() { 49 - const synced = useRef(false) 50 - const [status] = useForegroundPermissions() 51 - const [deviceGeolocation, setDeviceGeolocation] = useStorage(device, [ 52 - 'deviceGeolocation', 53 - ]) 54 - 55 - useEffect(() => { 56 - async function get() { 57 - // no need to set this more than once per session 58 - if (synced.current) return 59 - 60 - logger.debug('useSyncedDeviceGeolocation: checking perms') 61 - 62 - if (status?.granted) { 63 - const location = await getDeviceGeolocation() 64 - if (location) { 65 - logger.debug('useSyncedDeviceGeolocation: syncing location') 66 - setDeviceGeolocation(location) 67 - synced.current = true 68 - } 69 - } else { 70 - const hasCachedValue = device.get(['deviceGeolocation']) !== undefined 71 - 72 - /** 73 - * If we have a cached value, but user has revoked permissions, 74 - * quietly (will take effect lazily) clear this out. 75 - */ 76 - if (hasCachedValue) { 77 - logger.debug( 78 - 'useSyncedDeviceGeolocation: clearing cached location, perms revoked', 79 - ) 80 - device.set(['deviceGeolocation'], undefined) 81 - } 82 - } 83 - } 84 - 85 - get().catch(e => { 86 - logger.error('useSyncedDeviceGeolocation: failed to sync', { 87 - safeMessage: e, 88 - }) 89 - }) 90 - }, [status, setDeviceGeolocation]) 91 - 92 - return [deviceGeolocation, setDeviceGeolocation] as const 93 - }
-180
src/state/geolocation/util.ts
··· 1 - import { 2 - getCurrentPositionAsync, 3 - type LocationGeocodedAddress, 4 - reverseGeocodeAsync, 5 - } from 'expo-location' 6 - 7 - import {logger} from '#/state/geolocation/logger' 8 - import {type DeviceLocation} from '#/state/geolocation/types' 9 - import {type Device} from '#/storage' 10 - 11 - /** 12 - * Maps full US region names to their short codes. 13 - * 14 - * Context: in some cases, like on Android, we get the full region name instead 15 - * of the short code. We may need to expand this in the future to other 16 - * countries, hence the prefix. 17 - */ 18 - export const USRegionNameToRegionCode: { 19 - [regionName: string]: string 20 - } = { 21 - Alabama: 'AL', 22 - Alaska: 'AK', 23 - Arizona: 'AZ', 24 - Arkansas: 'AR', 25 - California: 'CA', 26 - Colorado: 'CO', 27 - Connecticut: 'CT', 28 - Delaware: 'DE', 29 - Florida: 'FL', 30 - Georgia: 'GA', 31 - Hawaii: 'HI', 32 - Idaho: 'ID', 33 - Illinois: 'IL', 34 - Indiana: 'IN', 35 - Iowa: 'IA', 36 - Kansas: 'KS', 37 - Kentucky: 'KY', 38 - Louisiana: 'LA', 39 - Maine: 'ME', 40 - Maryland: 'MD', 41 - Massachusetts: 'MA', 42 - Michigan: 'MI', 43 - Minnesota: 'MN', 44 - Mississippi: 'MS', 45 - Missouri: 'MO', 46 - Montana: 'MT', 47 - Nebraska: 'NE', 48 - Nevada: 'NV', 49 - ['New Hampshire']: 'NH', 50 - ['New Jersey']: 'NJ', 51 - ['New Mexico']: 'NM', 52 - ['New York']: 'NY', 53 - ['North Carolina']: 'NC', 54 - ['North Dakota']: 'ND', 55 - Ohio: 'OH', 56 - Oklahoma: 'OK', 57 - Oregon: 'OR', 58 - Pennsylvania: 'PA', 59 - ['Rhode Island']: 'RI', 60 - ['South Carolina']: 'SC', 61 - ['South Dakota']: 'SD', 62 - Tennessee: 'TN', 63 - Texas: 'TX', 64 - Utah: 'UT', 65 - Vermont: 'VT', 66 - Virginia: 'VA', 67 - Washington: 'WA', 68 - ['West Virginia']: 'WV', 69 - Wisconsin: 'WI', 70 - Wyoming: 'WY', 71 - } 72 - 73 - /** 74 - * Normalizes a `LocationGeocodedAddress` into a `DeviceLocation`. 75 - * 76 - * We don't want or care about the full location data, so we trim it down and 77 - * normalize certain fields, like region, into the format we need. 78 - */ 79 - export function normalizeDeviceLocation( 80 - location: LocationGeocodedAddress, 81 - ): DeviceLocation { 82 - let {isoCountryCode, region} = location 83 - 84 - if (region) { 85 - if (isoCountryCode === 'US') { 86 - region = USRegionNameToRegionCode[region] ?? region 87 - } 88 - } 89 - 90 - return { 91 - countryCode: isoCountryCode ?? undefined, 92 - regionCode: region ?? undefined, 93 - } 94 - } 95 - 96 - /** 97 - * Combines precise location data with the geolocation config fetched from the 98 - * IP service, with preference to the precise data. 99 - */ 100 - export function mergeGeolocation( 101 - location?: DeviceLocation, 102 - config?: Device['geolocation'], 103 - ): DeviceLocation { 104 - if (location?.countryCode) return location 105 - return { 106 - countryCode: config?.countryCode, 107 - regionCode: config?.regionCode, 108 - } 109 - } 110 - 111 - /** 112 - * Computes the geolocation status (age-restricted, age-blocked) based on the 113 - * given location and geolocation config. `location` here should be merged with 114 - * `mergeGeolocation()` ahead of time if needed. 115 - */ 116 - export function computeGeolocationStatus( 117 - location: DeviceLocation, 118 - config: Device['geolocation'], 119 - ) { 120 - /** 121 - * We can't do anything if we don't have this data. 122 - */ 123 - if (!location.countryCode) { 124 - return { 125 - ...location, 126 - isAgeRestrictedGeo: false, 127 - isAgeBlockedGeo: false, 128 - } 129 - } 130 - 131 - const isAgeRestrictedGeo = config?.ageRestrictedGeos?.some(rule => { 132 - if (rule.countryCode === location.countryCode) { 133 - if (!rule.regionCode) { 134 - return true // whole country is blocked 135 - } else if (rule.regionCode === location.regionCode) { 136 - return true 137 - } 138 - } 139 - }) 140 - 141 - const isAgeBlockedGeo = config?.ageBlockedGeos?.some(rule => { 142 - if (rule.countryCode === location.countryCode) { 143 - if (!rule.regionCode) { 144 - return true // whole country is blocked 145 - } else if (rule.regionCode === location.regionCode) { 146 - return true 147 - } 148 - } 149 - }) 150 - 151 - return { 152 - ...location, 153 - isAgeRestrictedGeo: !!isAgeRestrictedGeo, 154 - isAgeBlockedGeo: !!isAgeBlockedGeo, 155 - } 156 - } 157 - 158 - export async function getDeviceGeolocation(): Promise<DeviceLocation> { 159 - try { 160 - const geocode = await getCurrentPositionAsync() 161 - const locations = await reverseGeocodeAsync({ 162 - latitude: geocode.coords.latitude, 163 - longitude: geocode.coords.longitude, 164 - }) 165 - const location = locations.at(0) 166 - const normalized = location ? normalizeDeviceLocation(location) : undefined 167 - return { 168 - countryCode: normalized?.countryCode ?? undefined, 169 - regionCode: normalized?.regionCode ?? undefined, 170 - } 171 - } catch (e) { 172 - logger.error('getDeviceGeolocation: failed', { 173 - safeMessage: e, 174 - }) 175 - return { 176 - countryCode: undefined, 177 - regionCode: undefined, 178 - } 179 - } 180 - }
+1 -6
src/state/queries/post-feed.ts
··· 31 31 import {FeedTuner, type FeedTunerFn} from '#/lib/api/feed-manip' 32 32 import {DISCOVER_FEED_URI} from '#/lib/constants' 33 33 import {logger} from '#/logger' 34 - import {useAgeAssuranceContext} from '#/state/ageAssurance' 35 34 import {STALE} from '#/state/queries' 36 35 import {DEFAULT_LOGGED_OUT_PREFERENCES} from '#/state/queries/preferences/const' 37 36 import {useAgent} from '#/state/session' ··· 141 140 * available for the remainder of the session, so this delay only affects cold 142 141 * loads. -esb 143 142 */ 144 - const {isReady: isAgeAssuranceReady} = useAgeAssuranceContext() 145 143 const enabled = 146 - opts?.enabled !== false && 147 - Boolean(moderationOpts) && 148 - Boolean(preferences) && 149 - isAgeAssuranceReady 144 + opts?.enabled !== false && Boolean(moderationOpts) && Boolean(preferences) 150 145 const userInterests = aggregateUserInterests(preferences) 151 146 const followingPinnedIndex = 152 147 preferences?.savedFeeds?.findIndex(
+2
src/state/queries/post.ts
··· 26 26 const res = await agent.resolveHandle({ 27 27 handle: urip.host, 28 28 }) 29 + // @ts-expect-error TODO new-sdk-migration 29 30 urip.host = res.data.did 30 31 } 31 32 ··· 54 55 const res = await agent.resolveHandle({ 55 56 handle: urip.host, 56 57 }) 58 + // @ts-expect-error TODO new-sdk-migration 57 59 urip.host = res.data.did 58 60 } 59 61
+1
src/state/queries/postgate/index.ts
··· 36 36 const res = await agent.resolveHandle({ 37 37 handle: urip.host, 38 38 }) 39 + // @ts-expect-error TODO new-sdk-migration 39 40 urip.host = res.data.did 40 41 } 41 42
+9 -24
src/state/queries/preferences/index.ts
··· 10 10 import {replaceEqualDeep} from '#/lib/functions' 11 11 import {getAge} from '#/lib/strings/time' 12 12 import {logger} from '#/logger' 13 - import {useAgeAssuranceContext} from '#/state/ageAssurance' 14 - import {makeAgeRestrictedModerationPrefs} from '#/state/ageAssurance/const' 15 13 import {STALE} from '#/state/queries' 16 14 import { 17 15 DEFAULT_HOME_FEED_PREFS, ··· 24 22 } from '#/state/queries/preferences/types' 25 23 import {useAgent} from '#/state/session' 26 24 import {saveLabelers} from '#/state/session/agent-config' 25 + import {useAgeAssurance} from '#/ageAssurance' 27 26 28 27 export * from '#/state/queries/preferences/const' 29 28 export * from '#/state/queries/preferences/moderation' ··· 34 33 35 34 export function usePreferencesQuery() { 36 35 const agent = useAgent() 37 - const {isAgeRestricted} = useAgeAssuranceContext() 36 + const aa = useAgeAssurance() 38 37 39 38 return useQuery({ 40 39 staleTime: STALE.SECONDS.FIFTEEN, ··· 75 74 }, 76 75 select: useCallback( 77 76 (data: UsePreferencesQueryResponse) => { 78 - const isUnderage = (data.userAge || 0) < 18 79 - if (isUnderage || isAgeRestricted) { 77 + /** 78 + * Prefs are all downstream of age assurance now. For logged-out 79 + * users, we override moderation prefs based on AA state. 80 + */ 81 + if (aa.state.access !== aa.Access.Full) { 80 82 data = { 81 83 ...data, 82 - moderationPrefs: makeAgeRestrictedModerationPrefs( 83 - data.moderationPrefs, 84 - ), 84 + moderationPrefs: DEFAULT_LOGGED_OUT_PREFERENCES.moderationPrefs, 85 85 } 86 86 } 87 87 return data 88 88 }, 89 - [isAgeRestricted], 89 + [aa], 90 90 ), 91 91 }) 92 92 } ··· 160 160 return useMutation<void, unknown, {enabled: boolean}>({ 161 161 mutationFn: async ({enabled}) => { 162 162 await agent.setAdultContentEnabled(enabled) 163 - // triggers a refetch 164 - await queryClient.invalidateQueries({ 165 - queryKey: preferencesQueryKey, 166 - }) 167 - }, 168 - }) 169 - } 170 - 171 - export function usePreferencesSetBirthDateMutation() { 172 - const queryClient = useQueryClient() 173 - const agent = useAgent() 174 - 175 - return useMutation<void, unknown, {birthDate: Date}>({ 176 - mutationFn: async ({birthDate}: {birthDate: Date}) => { 177 - await agent.setPersonalDetails({birthDate: birthDate.toISOString()}) 178 163 // triggers a refetch 179 164 await queryClient.invalidateQueries({ 180 165 queryKey: preferencesQueryKey,
+1
src/state/queries/resolve-uri.ts
··· 17 17 const urip = new AtUri(uri || '') 18 18 const res = useResolveDidQuery(urip.host) 19 19 if (res.data) { 20 + // @ts-expect-error TODO new-sdk-migration 20 21 urip.host = res.data 21 22 return { 22 23 ...res,
+1
src/state/queries/threadgate/index.ts
··· 97 97 const res = await agent.resolveHandle({ 98 98 handle: urip.host, 99 99 }) 100 + // @ts-expect-error TODO new-sdk-migration 100 101 urip.host = res.data.did 101 102 } 102 103
+3
src/state/session/__tests__/session-test.ts
··· 10 10 }, 11 11 })) 12 12 13 + jest.mock('../../birthdate') 14 + jest.mock('../../../ageAssurance/data') 15 + 13 16 describe('session', () => { 14 17 it('can log in and out', () => { 15 18 let state = getInitialState([])
+143 -40
src/state/session/agent.ts
··· 1 1 import { 2 2 Agent as BaseAgent, 3 + type AppBskyActorProfile, 3 4 type AtprotoServiceType, 4 5 type AtpSessionData, 5 6 type AtpSessionEvent, 6 7 BskyAgent, 7 8 type Did, 9 + type Un$Typed, 8 10 } from '@atproto/api' 9 11 import {type FetchHandler} from '@atproto/api/dist/agent' 10 12 import {type SessionManager} from '@atproto/api/dist/session-manager' ··· 23 25 import {tryFetchGates} from '#/lib/statsig/statsig' 24 26 import {getAge} from '#/lib/strings/time' 25 27 import {logger} from '#/logger' 28 + import {snoozeBirthdateUpdateAllowedForDid} from '#/state/birthdate' 26 29 import {snoozeEmailConfirmationPrompt} from '#/state/shell/reminders' 30 + import { 31 + prefetchAgeAssuranceData, 32 + setBirthdateForDid, 33 + setCreatedAtForDid, 34 + } from '#/ageAssurance/data' 27 35 import {emitNetworkConfirmed, emitNetworkLost} from '../events' 28 36 import {addSessionErrorLog} from './logging' 29 37 import { ··· 77 85 } 78 86 } 79 87 88 + // after session is attached 89 + const aa = prefetchAgeAssuranceData({agent}) 90 + 80 91 agent.configureProxy(BLUESKY_PROXY_HEADER.get()) 81 92 82 - return agent.prepare(gates, moderation, onSessionChange) 93 + return agent.prepare({ 94 + resolvers: [gates, moderation, aa], 95 + onSessionChange, 96 + }) 83 97 } 84 98 85 99 export async function createAgentAndLogin( ··· 111 125 const account = agentToSessionAccountOrThrow(agent) 112 126 const gates = tryFetchGates(account.did, 'prefer-fresh-gates') 113 127 const moderation = configureModerationForAccount(agent, account) 128 + const aa = prefetchAgeAssuranceData({agent}) 114 129 115 130 agent.configureProxy(BLUESKY_PROXY_HEADER.get()) 116 131 117 - return agent.prepare(gates, moderation, onSessionChange) 132 + return agent.prepare({ 133 + resolvers: [gates, moderation, aa], 134 + onSessionChange, 135 + }) 118 136 } 119 137 120 138 export async function createAgentAndCreateAccount( ··· 156 174 const gates = tryFetchGates(account.did, 'prefer-fresh-gates') 157 175 const moderation = configureModerationForAccount(agent, account) 158 176 177 + const createdAt = new Date().toISOString() 178 + const birthdate = birthDate.toISOString() 179 + 180 + /* 181 + * Since we have a race with account creation, profile creation, and AA 182 + * state, set these values locally to ensure sync reads. Values are written 183 + * to the server in the next step, so on subsequent reloads, the server will 184 + * be the source of truth. 185 + */ 186 + setCreatedAtForDid({did: account.did, createdAt}) 187 + setBirthdateForDid({did: account.did, birthdate}) 188 + snoozeBirthdateUpdateAllowedForDid(account.did) 189 + // do this last 190 + const aa = prefetchAgeAssuranceData({agent}) 191 + 159 192 // Not awaited so that we can still get into onboarding. 160 193 // This is OK because we won't let you toggle adult stuff until you set the date. 161 194 if (IS_PROD_SERVICE(service)) { 162 - try { 163 - networkRetry(1, async () => { 164 - await agent.setPersonalDetails({birthDate: birthDate.toISOString()}) 165 - await agent.overwriteSavedFeeds([ 166 - { 167 - ...DISCOVER_SAVED_FEED, 168 - id: TID.nextStr(), 169 - }, 170 - { 171 - ...TIMELINE_SAVED_FEED, 172 - id: TID.nextStr(), 173 - }, 174 - ]) 175 - 176 - if (getAge(birthDate) < 18) { 177 - await agent.api.com.atproto.repo.putRecord({ 178 - repo: account.did, 179 - collection: 'chat.bsky.actor.declaration', 180 - rkey: 'self', 181 - record: { 182 - $type: 'chat.bsky.actor.declaration', 183 - allowIncoming: 'none', 195 + Promise.allSettled( 196 + [ 197 + networkRetry(3, () => { 198 + return agent.setPersonalDetails({ 199 + birthDate: birthdate, 200 + }) 201 + }).catch(e => { 202 + logger.info(`createAgentAndCreateAccount: failed to set birthDate`) 203 + throw e 204 + }), 205 + networkRetry(3, () => { 206 + return agent.upsertProfile(prev => { 207 + const next: Un$Typed<AppBskyActorProfile.Record> = prev || {} 208 + next.displayName = handle 209 + next.createdAt = createdAt 210 + return next 211 + }) 212 + }).catch(e => { 213 + logger.info( 214 + `createAgentAndCreateAccount: failed to set initial profile`, 215 + ) 216 + throw e 217 + }), 218 + networkRetry(1, () => { 219 + return agent.overwriteSavedFeeds([ 220 + { 221 + ...DISCOVER_SAVED_FEED, 222 + id: TID.nextStr(), 184 223 }, 224 + { 225 + ...TIMELINE_SAVED_FEED, 226 + id: TID.nextStr(), 227 + }, 228 + ]) 229 + }).catch(e => { 230 + logger.info( 231 + `createAgentAndCreateAccount: failed to set initial feeds`, 232 + ) 233 + throw e 234 + }), 235 + getAge(birthDate) < 18 && 236 + networkRetry(3, () => { 237 + return agent.com.atproto.repo.putRecord({ 238 + repo: account.did, 239 + collection: 'chat.bsky.actor.declaration', 240 + rkey: 'self', 241 + record: { 242 + $type: 'chat.bsky.actor.declaration', 243 + allowIncoming: 'none', 244 + }, 245 + }) 246 + }).catch(e => { 247 + logger.info( 248 + `createAgentAndCreateAccount: failed to set chat declaration`, 249 + ) 250 + throw e 251 + }), 252 + ].filter(Boolean), 253 + ).then(promises => { 254 + const rejected = promises.filter(p => p.status === 'rejected') 255 + if (rejected.length > 0) { 256 + logger.error( 257 + `session: createAgentAndCreateAccount failed to save personal details and feeds`, 258 + ) 259 + } 260 + }) 261 + } else { 262 + Promise.allSettled( 263 + [ 264 + networkRetry(3, () => { 265 + return agent.setPersonalDetails({ 266 + birthDate: birthDate.toISOString(), 185 267 }) 186 - } 187 - }) 188 - } catch (e: any) { 189 - logger.error(e, { 190 - message: `session: createAgentAndCreateAccount failed to save personal details and feeds`, 191 - }) 192 - } 193 - } else { 194 - agent.setPersonalDetails({birthDate: birthDate.toISOString()}) 268 + }).catch(e => { 269 + logger.info(`createAgentAndCreateAccount: failed to set birthDate`) 270 + throw e 271 + }), 272 + networkRetry(3, () => { 273 + return agent.upsertProfile(prev => { 274 + const next: Un$Typed<AppBskyActorProfile.Record> = prev || {} 275 + next.createdAt = prev?.createdAt || new Date().toISOString() 276 + return next 277 + }) 278 + }).catch(e => { 279 + logger.info( 280 + `createAgentAndCreateAccount: failed to set initial profile`, 281 + ) 282 + throw e 283 + }), 284 + ].filter(Boolean), 285 + ).then(promises => { 286 + const rejected = promises.filter(p => p.status === 'rejected') 287 + if (rejected.length > 0) { 288 + logger.error( 289 + `session: createAgentAndCreateAccount failed to save personal details and feeds`, 290 + ) 291 + } 292 + }) 195 293 } 196 294 197 295 try { ··· 203 301 204 302 agent.configureProxy(BLUESKY_PROXY_HEADER.get()) 205 303 206 - return agent.prepare(gates, moderation, onSessionChange) 304 + return agent.prepare({ 305 + resolvers: [gates, moderation, aa], 306 + onSessionChange, 307 + }) 207 308 } 208 309 209 310 export function agentToSessionAccountOrThrow(agent: BskyAgent): SessionAccount { ··· 306 407 }) 307 408 } 308 409 309 - async prepare( 410 + async prepare({ 411 + resolvers, 412 + onSessionChange, 413 + }: { 310 414 // Not awaited in the calling code so we can delay blocking on them. 311 - gates: Promise<void>, 312 - moderation: Promise<void>, 415 + resolvers: Promise<unknown>[] 313 416 onSessionChange: ( 314 417 agent: BskyAgent, 315 418 did: string, 316 419 event: AtpSessionEvent, 317 - ) => void, 318 - ) { 420 + ) => void 421 + }) { 319 422 // There's nothing else left to do, so block on them here. 320 - await Promise.all([gates, moderation]) 423 + await Promise.all(resolvers) 321 424 322 425 // Now the agent is ready. 323 426 const account = agentToSessionAccountOrThrow(this)
+24 -4
src/state/session/index.tsx
··· 24 24 type SessionApiContext, 25 25 type SessionStateContext, 26 26 } from '#/state/session/types' 27 + import {useOnboardingDispatch} from '#/state/shell/onboarding' 28 + import { 29 + clearAgeAssuranceData, 30 + clearAgeAssuranceDataForDid, 31 + } from '#/ageAssurance/data' 27 32 28 33 const StateContext = React.createContext<SessionStateContext>({ 29 34 accounts: [], ··· 91 96 const cancelPendingTask = useOneTaskAtATime() 92 97 const [store] = React.useState(() => new SessionStore()) 93 98 const state = React.useSyncExternalStore(store.subscribe, store.getState) 99 + const onboardingDispatch = useOnboardingDispatch() 94 100 95 101 const onAgentSessionChange = React.useCallback( 96 102 (agent: BskyAgent, accountDid: string, sessionEvent: AtpSessionEvent) => { ··· 166 172 logContext => { 167 173 addSessionDebugLog({type: 'method:start', method: 'logout'}) 168 174 cancelPendingTask() 175 + const prevState = store.getState() 169 176 store.dispatch({ 170 177 type: 'logged-out-current-account', 171 178 }) ··· 175 182 {statsig: true}, 176 183 ) 177 184 addSessionDebugLog({type: 'method:end', method: 'logout'}) 185 + if (prevState.currentAgentState.did) { 186 + clearAgeAssuranceDataForDid({did: prevState.currentAgentState.did}) 187 + } 188 + // reset onboarding flow on logout 189 + onboardingDispatch({type: 'skip'}) 178 190 }, 179 - [store, cancelPendingTask], 191 + [store, cancelPendingTask, onboardingDispatch], 180 192 ) 181 193 182 194 const logoutEveryAccount = React.useCallback< ··· 194 206 {statsig: true}, 195 207 ) 196 208 addSessionDebugLog({type: 'method:end', method: 'logout'}) 209 + clearAgeAssuranceData() 210 + // reset onboarding flow on logout 211 + onboardingDispatch({type: 'skip'}) 197 212 }, 198 - [store, cancelPendingTask], 213 + [store, cancelPendingTask, onboardingDispatch], 199 214 ) 200 215 201 216 const resumeSession = React.useCallback<SessionApiContext['resumeSession']>( 202 - async storedAccount => { 217 + async (storedAccount, isSwitchingAccounts = false) => { 203 218 addSessionDebugLog({ 204 219 type: 'method:start', 205 220 method: 'resumeSession', ··· 220 235 newAccount: account, 221 236 }) 222 237 addSessionDebugLog({type: 'method:end', method: 'resumeSession', account}) 238 + if (isSwitchingAccounts) { 239 + // reset onboarding flow on switch account 240 + onboardingDispatch({type: 'skip'}) 241 + } 223 242 }, 224 - [store, onAgentSessionChange, cancelPendingTask], 243 + [store, onAgentSessionChange, cancelPendingTask, onboardingDispatch], 225 244 ) 226 245 227 246 const partialRefreshSession = React.useCallback< ··· 254 273 accountDid: account.did, 255 274 }) 256 275 addSessionDebugLog({type: 'method:end', method: 'removeAccount', account}) 276 + clearAgeAssuranceDataForDid({did: account.did}) 257 277 }, 258 278 [store, cancelPendingTask], 259 279 )
+4 -1
src/state/session/types.ts
··· 38 38 logoutEveryAccount: ( 39 39 logContext: LogEvents['account:loggedOut']['logContext'], 40 40 ) => void 41 - resumeSession: (account: SessionAccount) => Promise<void> 41 + resumeSession: ( 42 + account: SessionAccount, 43 + isSwitchingAccounts?: boolean, 44 + ) => Promise<void> 42 45 removeAccount: (account: SessionAccount) => void 43 46 /** 44 47 * Calls `getSession` and updates select fields on the current account and
+1 -4
src/state/shell/index.tsx
··· 2 2 import {Provider as DrawerOpenProvider} from './drawer-open' 3 3 import {Provider as DrawerSwipableProvider} from './drawer-swipe-disabled' 4 4 import {Provider as MinimalModeProvider} from './minimal-mode' 5 - import {Provider as OnboardingProvider} from './onboarding' 6 5 import {Provider as ShellLayoutProvder} from './shell-layout' 7 6 import {Provider as TickEveryMinuteProvider} from './tick-every-minute' 8 7 ··· 23 22 <DrawerSwipableProvider> 24 23 <MinimalModeProvider> 25 24 <ColorModeProvider> 26 - <OnboardingProvider> 27 - <TickEveryMinuteProvider>{children}</TickEveryMinuteProvider> 28 - </OnboardingProvider> 25 + <TickEveryMinuteProvider>{children}</TickEveryMinuteProvider> 29 26 </ColorModeProvider> 30 27 </MinimalModeProvider> 31 28 </DrawerSwipableProvider>
+1
src/state/unstable-post-source.tsx
··· 81 81 */ 82 82 export function buildPostSourceKey(key: string, handle: string) { 83 83 const urip = new AtUri(key) 84 + // @ts-expect-error TODO new-sdk-migration 84 85 urip.host = handle 85 86 return urip.toString() 86 87 }
+19 -4
src/storage/schema.ts
··· 1 1 import {type ID as PolicyUpdate202508} from '#/components/PolicyUpdateOverlay/updates/202508/config' 2 + import {type Geolocation} from '#/geolocation/types' 2 3 3 4 /** 4 5 * Device data that's specific to the device and does not vary based account ··· 25 26 regionCode: string | undefined 26 27 }[] 27 28 } 29 + 30 + /** 31 + * The raw response from the geolocation service, if available. We 32 + * cache this here and update it lazily on session start. 33 + */ 34 + geolocationServiceResponse?: Geolocation 28 35 /** 29 36 * The GPS-based geolocation, if the user has granted permission. 30 37 */ 31 - deviceGeolocation?: { 32 - countryCode: string | undefined 33 - regionCode: string | undefined 34 - } 38 + deviceGeolocation?: Geolocation 39 + /** 40 + * The merged geolocation, combining `geolocationServiceResponse` and 41 + * `deviceGeolocation`, with preference to `deviceGeolocation`. 42 + */ 43 + mergedGeolocation?: Geolocation 35 44 36 45 trendingBetaEnabled: boolean 37 46 devMode: boolean ··· 49 58 export type Account = { 50 59 searchTermHistory?: string[] 51 60 searchAccountHistory?: string[] 61 + 62 + /** 63 + * The ISO date string of when this account's birthdate was last updated on 64 + * this device. 65 + */ 66 + birthdateLastUpdatedAt?: string 52 67 }
+15 -4
src/view/screens/Storybook/index.tsx
··· 3 3 import {useNavigation} from '@react-navigation/native' 4 4 5 5 import {type NavigationProp} from '#/lib/routes/types' 6 - import {Sentry} from '#/logger/sentry/lib' 7 6 import {useSetThemePrefs} from '#/state/shell' 8 7 import {ListContained} from '#/view/screens/Storybook/ListContained' 9 8 import {atoms as a, ThemeProvider} from '#/alf' 10 9 import {Button, ButtonText} from '#/components/Button' 11 10 import * as Layout from '#/components/Layout' 11 + import { 12 + useDeviceGeolocationApi, 13 + useRequestDeviceGeolocation, 14 + } from '#/geolocation' 12 15 import {Admonitions} from './Admonitions' 13 16 import {Breakpoints} from './Breakpoints' 14 17 import {Buttons} from './Buttons' ··· 45 48 const {setColorMode, setDarkTheme} = useSetThemePrefs() 46 49 const [showContainedList, setShowContainedList] = React.useState(false) 47 50 const navigation = useNavigation<NavigationProp>() 51 + const requestDeviceGeolocation = useRequestDeviceGeolocation() 52 + const {setDeviceGeolocation} = useDeviceGeolocationApi() 48 53 49 54 return ( 50 55 <> ··· 97 102 <ButtonText>Open Shared Prefs Tester</ButtonText> 98 103 </Button> 99 104 <Button 100 - color="negative" 105 + color="primary_subtle" 101 106 size="large" 102 - onPress={() => Sentry.nativeCrash()} 107 + onPress={() => 108 + requestDeviceGeolocation().then(req => { 109 + if (req.granted && req.location) { 110 + setDeviceGeolocation(req.location) 111 + } 112 + }) 113 + } 103 114 label="crash"> 104 - <ButtonText>Sentry Crash</ButtonText> 115 + <ButtonText>Get GPS Location</ButtonText> 105 116 </Button> 106 117 107 118 <ThemeProvider theme="light">
+8 -5
src/view/shell/index.tsx
··· 13 13 import {isStateAtTabRoot} from '#/lib/routes/helpers' 14 14 import {isAndroid, isIOS} from '#/platform/detection' 15 15 import {useDialogFullyExpandedCountContext} from '#/state/dialogs' 16 - import {useGeolocationStatus} from '#/state/geolocation' 17 16 import {useSession} from '#/state/session' 18 17 import { 19 18 useIsDrawerOpen, ··· 27 26 import {atoms as a, select, useTheme} from '#/alf' 28 27 import {setSystemUITheme} from '#/alf/util/systemUI' 29 28 import {AgeAssuranceRedirectDialog} from '#/components/ageAssurance/AgeAssuranceRedirectDialog' 30 - import {BlockedGeoOverlay} from '#/components/BlockedGeoOverlay' 31 29 import {EmailDialog} from '#/components/dialogs/EmailDialog' 32 30 import {InAppBrowserConsentDialog} from '#/components/dialogs/InAppBrowserConsent' 33 31 import {LinkWarningDialog} from '#/components/dialogs/LinkWarning' ··· 38 36 usePolicyUpdateContext, 39 37 } from '#/components/PolicyUpdateOverlay' 40 38 import {Outlet as PortalOutlet} from '#/components/Portal' 39 + import {useAgeAssurance} from '#/ageAssurance' 40 + import {NoAccessScreen} from '#/ageAssurance/components/NoAccessScreen' 41 + import {RedirectOverlay} from '#/ageAssurance/components/RedirectOverlay' 41 42 import {RoutesContainer, TabsNavigator} from '#/Navigation' 42 43 import {BottomSheetOutlet} from '../../../modules/bottom-sheet' 43 44 import {updateActiveViewAsync} from '../../../modules/expo-bluesky-swiss-army/src/VisibilityView' ··· 193 194 194 195 export function Shell() { 195 196 const t = useTheme() 196 - const {status: geolocation} = useGeolocationStatus() 197 + const aa = useAgeAssurance() 197 198 const fullyExpandedCount = useDialogFullyExpandedCountContext() 198 199 199 200 useIntentHandler() ··· 213 214 navigationBar: t.name !== 'light' ? 'light' : 'dark', 214 215 }} 215 216 /> 216 - {geolocation?.isAgeBlockedGeo ? ( 217 - <BlockedGeoOverlay /> 217 + {aa.state.access === aa.Access.None ? ( 218 + <NoAccessScreen /> 218 219 ) : ( 219 220 <RoutesContainer> 220 221 <ShellInner /> 221 222 </RoutesContainer> 222 223 )} 224 + 225 + <RedirectOverlay /> 223 226 </View> 224 227 ) 225 228 }
+8 -5
src/view/shell/index.web.tsx
··· 8 8 import {useIntentHandler} from '#/lib/hooks/useIntentHandler' 9 9 import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' 10 10 import {type NavigationProp} from '#/lib/routes/types' 11 - import {useGeolocationStatus} from '#/state/geolocation' 12 11 import {useIsDrawerOpen, useSetDrawerOpen} from '#/state/shell' 13 12 import {useComposerKeyboardShortcut} from '#/state/shell/composer/useComposerKeyboardShortcut' 14 13 import {useCloseAllActiveElements} from '#/state/util' ··· 17 16 import {ErrorBoundary} from '#/view/com/util/ErrorBoundary' 18 17 import {atoms as a, select, useTheme} from '#/alf' 19 18 import {AgeAssuranceRedirectDialog} from '#/components/ageAssurance/AgeAssuranceRedirectDialog' 20 - import {BlockedGeoOverlay} from '#/components/BlockedGeoOverlay' 21 19 import {EmailDialog} from '#/components/dialogs/EmailDialog' 22 20 import {LinkWarningDialog} from '#/components/dialogs/LinkWarning' 23 21 import {MutedWordsDialog} from '#/components/dialogs/MutedWords' ··· 29 27 } from '#/components/PolicyUpdateOverlay' 30 28 import {Outlet as PortalOutlet} from '#/components/Portal' 31 29 import {WelcomeModal} from '#/components/WelcomeModal' 30 + import {useAgeAssurance} from '#/ageAssurance' 31 + import {NoAccessScreen} from '#/ageAssurance/components/NoAccessScreen' 32 + import {RedirectOverlay} from '#/ageAssurance/components/RedirectOverlay' 32 33 import {FlatNavigator, RoutesContainer} from '#/Navigation' 33 34 import {Composer} from './Composer.web' 34 35 import {DrawerContent} from './Drawer' ··· 139 140 140 141 export function Shell() { 141 142 const t = useTheme() 142 - const {status: geolocation} = useGeolocationStatus() 143 + const aa = useAgeAssurance() 143 144 return ( 144 145 <View style={[a.util_screen_outer, t.atoms.bg]}> 145 - {geolocation?.isAgeBlockedGeo ? ( 146 - <BlockedGeoOverlay /> 146 + {aa.state.access === aa.Access.None ? ( 147 + <NoAccessScreen /> 147 148 ) : ( 148 149 <RoutesContainer> 149 150 <ShellInner /> 150 151 </RoutesContainer> 151 152 )} 153 + 154 + <RedirectOverlay /> 152 155 </View> 153 156 ) 154 157 }
+12 -4
web/index.html
··· 73 73 width: 100%; 74 74 } 75 75 #splash { 76 + display: flex; 76 77 position: fixed; 78 + top: 0; 79 + bottom: 0; 80 + left: 0; 81 + right: 0; 82 + align-items: center; 83 + justify-content: center; 84 + } 85 + #splash svg { 86 + position: relative; 87 + top: -50px; 77 88 width: 100px; 78 - left: 50%; 79 - top: 50%; 80 - transform: translateX(-50%) translateY(-50%) translateY(-50px); 81 89 } 82 90 /** 83 91 * We need these styles to prevent shifting due to scrollbar show/hide on ··· 146 154 <div id="root"> 147 155 <div id="splash"> 148 156 <!-- Bluesky SVG --> 149 - <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 360 320"><path fill="#0085ff" d="M180 142c-16.3-31.7-60.7-90.8-102-120C38.5-5.9 23.4-1 13.5 3.4 2.1 8.6 0 26.2 0 36.5c0 10.4 5.7 84.8 9.4 97.2 12.2 41 55.7 55 95.7 50.5-58.7 8.6-110.8 30-42.4 106.1 75.1 77.9 103-16.7 117.3-64.6 14.3 48 30.8 139 116 64.6 64-64.6 17.6-97.5-41.1-106.1 40 4.4 83.5-9.5 95.7-50.5 3.7-12.4 9.4-86.8 9.4-97.2 0-10.3-2-27.9-13.5-33C336.5-1 321.5-6 282 22c-41.3 29.2-85.7 88.3-102 120Z"/></svg> 157 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 57"><path fill="#006AFF" d="M13.873 3.805C21.21 9.332 29.103 20.537 32 26.55v15.882c0-.338-.13.044-.41.867-1.512 4.456-7.418 21.847-20.923 7.944-7.111-7.32-3.819-14.64 9.125-16.85-7.405 1.264-15.73-.825-18.014-9.015C1.12 23.022 0 8.51 0 6.55 0-3.268 8.579-.182 13.873 3.805ZM50.127 3.805C42.79 9.332 34.897 20.537 32 26.55v15.882c0-.338.13.044.41.867 1.512 4.456 7.418 21.847 20.923 7.944 7.111-7.32 3.819-14.64-9.125-16.85 7.405 1.264 15.73-.825 18.014-9.015C62.88 23.022 64 8.51 64 6.55c0-9.818-8.578-6.732-13.873-2.745Z"/></svg> 150 158 </div> 151 159 </div> 152 160 </body>
+71 -19
yarn.lock
··· 84 84 tlds "^1.234.0" 85 85 zod "^3.23.8" 86 86 87 - "@atproto/api@^0.18.0": 88 - version "0.18.0" 89 - resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.18.0.tgz#d8c54ddc4521d915f0af238a4bfebd119e18197f" 90 - integrity sha512-2GxKPhhvMocDjRU7VpNj+cvCdmCHVAmRwyfNgRLMrJtPZvrosFoi9VATX+7eKN0FZvYvy8KdLSkCcpP2owH3IA== 87 + "@atproto/api@^0.18.4": 88 + version "0.18.4" 89 + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.18.4.tgz#e6742f3b81acec2bcf63dd3787304166eb2891cb" 90 + integrity sha512-+kSxto/GRFXRFFlGwfERrwEKnC6OqTgK34BUToer/Fv08q4WMR+GYPRabbWlnDoJWu3owcQfeYdcblQ88vi16g== 91 91 dependencies: 92 - "@atproto/common-web" "^0.4.3" 93 - "@atproto/lexicon" "^0.5.1" 94 - "@atproto/syntax" "^0.4.1" 95 - "@atproto/xrpc" "^0.7.5" 92 + "@atproto/common-web" "^0.4.6" 93 + "@atproto/lexicon" "^0.5.2" 94 + "@atproto/syntax" "^0.4.2" 95 + "@atproto/xrpc" "^0.7.6" 96 96 await-lock "^2.2.2" 97 97 multiformats "^9.9.0" 98 98 tlds "^1.234.0" ··· 192 192 uint8arrays "3.0.0" 193 193 zod "^3.23.8" 194 194 195 + "@atproto/common-web@^0.4.4", "@atproto/common-web@^0.4.6": 196 + version "0.4.6" 197 + resolved "https://registry.yarnpkg.com/@atproto/common-web/-/common-web-0.4.6.tgz#e32395d44d812610fd99f718b8644308b828d68b" 198 + integrity sha512-+2mG/1oBcB/ZmYIU1ltrFMIiuy9aByKAkb2Fos/0eTdczcLBaH17k0KoxMGvhfsujN2r62XlanOAMzysa7lv1g== 199 + dependencies: 200 + "@atproto/lex-data" "0.0.2" 201 + "@atproto/lex-json" "0.0.2" 202 + zod "^3.23.8" 203 + 195 204 "@atproto/common@0.1.0": 196 205 version "0.1.0" 197 206 resolved "https://registry.yarnpkg.com/@atproto/common/-/common-0.1.0.tgz#4216a8fef5b985ab62ac21252a0f8ca0f4a0f210" ··· 301 310 multiformats "^9.9.0" 302 311 zod "^3.23.8" 303 312 313 + "@atproto/lex-data@0.0.2": 314 + version "0.0.2" 315 + resolved "https://registry.yarnpkg.com/@atproto/lex-data/-/lex-data-0.0.2.tgz#f90e7ac52dd6056199a84efc7a3c5196de7ceb63" 316 + integrity sha512-euV2rDGi+coH8qvZOU+ieUOEbwPwff9ca6IiXIqjZJ76AvlIpj7vtAyIRCxHUW2BoU6h9yqyJgn9MKD2a7oIwg== 317 + dependencies: 318 + "@atproto/syntax" "0.4.2" 319 + multiformats "^9.9.0" 320 + tslib "^2.8.1" 321 + uint8arrays "3.0.0" 322 + unicode-segmenter "^0.14.0" 323 + 324 + "@atproto/lex-json@0.0.2": 325 + version "0.0.2" 326 + resolved "https://registry.yarnpkg.com/@atproto/lex-json/-/lex-json-0.0.2.tgz#c4d3b6a8e965898cbc80478ecd461ddd8ac38493" 327 + integrity sha512-Pd72lO+l2rhOTutnf11omh9ZkoB/elbzE3HSmn2wuZlyH1mRhTYvoH8BOGokWQwbZkCE8LL3nOqMT3gHCD2l7g== 328 + dependencies: 329 + "@atproto/lex-data" "0.0.2" 330 + tslib "^2.8.1" 331 + 304 332 "@atproto/lexicon-resolver@0.2.2", "@atproto/lexicon-resolver@^0.2.2": 305 333 version "0.2.2" 306 334 resolved "https://registry.yarnpkg.com/@atproto/lexicon-resolver/-/lexicon-resolver-0.2.2.tgz#2a91a1908f6b327c41cb5c290eb80aed5ef593c0" ··· 320 348 integrity sha512-y8AEtYmfgVl4fqFxqXAeGvhesiGkxiy3CWoJIfsFDDdTlZUC8DFnZrYhcqkIop3OlCkkljvpSJi1hbeC1tbi8A== 321 349 dependencies: 322 350 "@atproto/common-web" "^0.4.3" 351 + "@atproto/syntax" "^0.4.1" 352 + iso-datestring-validator "^2.2.2" 353 + multiformats "^9.9.0" 354 + zod "^3.23.8" 355 + 356 + "@atproto/lexicon@^0.5.2": 357 + version "0.5.2" 358 + resolved "https://registry.yarnpkg.com/@atproto/lexicon/-/lexicon-0.5.2.tgz#c2fb39b952644c9d88203850e0d61a26b39338ec" 359 + integrity sha512-lRmJgMA8f5j7VB5Iu5cp188ald5FuI4FlmZ7nn6EBrk1dgOstWVrI5Ft6K3z2vjyLZRG6nzknlsw+tDP63p7bQ== 360 + dependencies: 361 + "@atproto/common-web" "^0.4.4" 323 362 "@atproto/syntax" "^0.4.1" 324 363 iso-datestring-validator "^2.2.2" 325 364 multiformats "^9.9.0" ··· 516 555 resolved "https://registry.yarnpkg.com/@atproto/syntax/-/syntax-0.4.1.tgz#f77bc610ae0914449ff3f4731861e3da429915f5" 517 556 integrity sha512-CJdImtLAiFO+0z3BWTtxwk6aY5w4t8orHTMVJgkf++QRJWTxPbIFko/0hrkADB7n2EruDxDSeAgfUGehpH6ngw== 518 557 558 + "@atproto/syntax@0.4.2", "@atproto/syntax@^0.4.2": 559 + version "0.4.2" 560 + resolved "https://registry.yarnpkg.com/@atproto/syntax/-/syntax-0.4.2.tgz#a83ff62b82bf84308d78ad836c802bad6a52174a" 561 + integrity sha512-X9XSRPinBy/0VQ677j8VXlBsYSsUXaiqxWVpGGxJYsAhugdQRb0jqaVKJFtm6RskeNkV6y9xclSUi9UYG/COrA== 562 + 519 563 "@atproto/xrpc-server@^0.9.5": 520 564 version "0.9.5" 521 565 resolved "https://registry.yarnpkg.com/@atproto/xrpc-server/-/xrpc-server-0.9.5.tgz#3a036ce2db85bcac40103fd160fef3ed7c364e2b" ··· 540 584 integrity sha512-MUYNn5d2hv8yVegRL0ccHvTHAVj5JSnW07bkbiaz96UH45lvYNRVwt44z+yYVnb0/mvBzyD3/ZQ55TRGt7fHkA== 541 585 dependencies: 542 586 "@atproto/lexicon" "^0.5.1" 587 + zod "^3.23.8" 588 + 589 + "@atproto/xrpc@^0.7.6": 590 + version "0.7.6" 591 + resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.7.6.tgz#bc12b0e37f81fa76589691634d4fac9774fd0cb5" 592 + integrity sha512-RvCf4j0JnKYWuz3QzsYCntJi3VuiAAybQsMIUw2wLWcHhchO9F7UaBZINLL2z0qc/cYWPv5NSwcVydMseoCZLA== 593 + dependencies: 594 + "@atproto/lexicon" "^0.5.2" 543 595 zod "^3.23.8" 544 596 545 597 "@aws-crypto/crc32@5.2.0": ··· 7189 7241 resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.25.0.tgz#e08ed0a9fad34c8005d1a282e57280031ac50cdc" 7190 7242 integrity sha512-vlobHP64HTuSE68lWF1mEhwSRC5Q7gaT+a/m9S+ItuN+ruSOxe1rFnR9j0ACWQ314BPhBEVKfBQ6mHL0OWfdbQ== 7191 7243 7192 - "@tanstack/query-core@5.8.1": 7193 - version "5.8.1" 7194 - resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.8.1.tgz#5215a028370d9b2f32e83787a0ea119e2f977996" 7195 - integrity sha512-Y0enatz2zQXBAsd7XmajlCs+WaitdR7dIFkqz9Xd7HL4KV04JOigWVreYseTmNH7YFSBSC/BJ9uuNp1MAf+GfA== 7196 - 7197 7244 "@tanstack/query-persist-client-core@5.25.0": 7198 7245 version "5.25.0" 7199 7246 resolved "https://registry.yarnpkg.com/@tanstack/query-persist-client-core/-/query-persist-client-core-5.25.0.tgz#52fa634a8067d7b965854a532a33077fd4df0eff" ··· 7208 7255 dependencies: 7209 7256 "@tanstack/query-persist-client-core" "5.25.0" 7210 7257 7211 - "@tanstack/react-query@^5.8.1": 7212 - version "5.8.1" 7213 - resolved "https://registry.yarnpkg.com/@tanstack/react-query/-/react-query-5.8.1.tgz#22a122016e23a39acd90341954a895980ec21ade" 7214 - integrity sha512-YMagxS8iNPOLg0pK6WOjdSDlAvWKOf69udLOwQrBVmkC2SRLNLko7elo5Ro3ptlJkXvTVHidxC/h5KGi5bH1XQ== 7258 + "@tanstack/react-query@5.25.0": 7259 + version "5.25.0" 7260 + resolved "https://registry.yarnpkg.com/@tanstack/react-query/-/react-query-5.25.0.tgz#f4dac794cf10dd956aa56dbbdf67049a5ba2669d" 7261 + integrity sha512-u+n5R7mLO7RmeiIonpaCRVXNRWtZEef/aVZ/XGWRPa7trBIvGtzlfo0Ah7ZtnTYfrKEVwnZ/tzRCBcoiqJ/tFw== 7215 7262 dependencies: 7216 - "@tanstack/query-core" "5.8.1" 7263 + "@tanstack/query-core" "5.25.0" 7217 7264 7218 7265 "@testing-library/jest-native@^5.4.3": 7219 7266 version "5.4.3" ··· 19041 19088 minimist "^1.2.6" 19042 19089 strip-bom "^3.0.0" 19043 19090 19044 - tslib@2, tslib@^2.6.2: 19091 + tslib@2, tslib@^2.6.2, tslib@^2.8.1: 19045 19092 version "2.8.1" 19046 19093 resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" 19047 19094 integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== ··· 19322 19369 version "2.1.0" 19323 19370 resolved "https://registry.yarnpkg.com/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz#43d41e3be698bd493ef911077c9b131f827e8ccd" 19324 19371 integrity sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w== 19372 + 19373 + unicode-segmenter@^0.14.0: 19374 + version "0.14.0" 19375 + resolved "https://registry.yarnpkg.com/unicode-segmenter/-/unicode-segmenter-0.14.0.tgz#090128182bcc710327a1b7e4af4f5834444eaa61" 19376 + integrity sha512-AH4lhPCJANUnSLEKnM4byboctePJzltF4xj8b+NbNiYeAkAXGh7px2K/4NANFp7dnr6+zB3e6HLu8Jj8SKyvYg== 19325 19377 19326 19378 unimodules-app-loader@~6.0.7: 19327 19379 version "6.0.7"