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 37 # bapp-config web worker URL 38 BAPP_CONFIG_DEV_URL= 39 - 40 - # Dev-only passthrough value for bapp-config web worker 41 - BAPP_CONFIG_DEV_BYPASS_SECRET=
··· 36 37 # bapp-config web worker URL 38 BAPP_CONFIG_DEV_URL=
+4 -4
__tests__/lib/string.test.ts
··· 957 }) 958 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' 961 expect(parseStarterPackUri(validAtUri)).toEqual({ 962 - name: 'did:123', 963 rkey: 'rkey', 964 }) 965 }) 966 967 it('returns null when the at uri has no rkey', () => { 968 - const validAtUri = 'at://did:123/app.bsky.graph.starterpack' 969 expect(parseStarterPackUri(validAtUri)).toEqual(null) 970 }) 971 972 it('returns null when the collection is not app.bsky.graph.starterpack', () => { 973 - const validAtUri = 'at://did:123/app.bsky.graph.list/rkey' 974 expect(parseStarterPackUri(validAtUri)).toEqual(null) 975 }) 976
··· 957 }) 958 959 it('returns the at uri when the input is a valid starterpack at uri', () => { 960 + const validAtUri = 'at://did:plc:123/app.bsky.graph.starterpack/rkey' 961 expect(parseStarterPackUri(validAtUri)).toEqual({ 962 + name: 'did:plc:123', 963 rkey: 'rkey', 964 }) 965 }) 966 967 it('returns null when the at uri has no rkey', () => { 968 + const validAtUri = 'at://did:plc:123/app.bsky.graph.starterpack' 969 expect(parseStarterPackUri(validAtUri)).toEqual(null) 970 }) 971 972 it('returns null when the collection is not app.bsky.graph.starterpack', () => { 973 + const validAtUri = 'at://did:plc:123/app.bsky.graph.list/rkey' 974 expect(parseStarterPackUri(validAtUri)).toEqual(null) 975 }) 976
+12 -4
bskyweb/templates/base.html
··· 68 width: 100%; 69 } 70 #splash { 71 position: fixed; 72 width: 100px; 73 - left: 50%; 74 - top: 50%; 75 - transform: translateX(-50%) translateY(-50%) translateY(-50px); 76 } 77 /** 78 * We need these styles to prevent shifting due to scrollbar show/hide on ··· 106 <div id="root"> 107 <div id="splash"> 108 <!-- 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> 110 </div> 111 </div> 112
··· 68 width: 100%; 69 } 70 #splash { 71 + display: flex; 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; 83 width: 100px; 84 } 85 /** 86 * We need these styles to prevent shifting due to scrollbar show/hide on ··· 114 <div id="root"> 115 <div id="splash"> 116 <!-- Bluesky 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> 118 </div> 119 </div> 120
+2 -2
package.json
··· 72 "icons:optimize": "svgo -f ./assets/icons" 73 }, 74 "dependencies": { 75 - "@atproto/api": "^0.18.0", 76 "@bitdrift/react-native": "^0.6.8", 77 "@braintree/sanitize-url": "^6.0.2", 78 "@bsky.app/alf": "^0.1.5", ··· 103 "@react-navigation/native-stack": "^7.3.13", 104 "@sentry/react-native": "~6.20.0", 105 "@tanstack/query-async-storage-persister": "^5.25.0", 106 - "@tanstack/react-query": "^5.8.1", 107 "@tanstack/react-query-persist-client": "^5.25.0", 108 "@tiptap/core": "^2.9.1", 109 "@tiptap/extension-document": "^2.9.1",
··· 72 "icons:optimize": "svgo -f ./assets/icons" 73 }, 74 "dependencies": { 75 + "@atproto/api": "^0.18.4", 76 "@bitdrift/react-native": "^0.6.8", 77 "@braintree/sanitize-url": "^6.0.2", 78 "@bsky.app/alf": "^0.1.5", ··· 103 "@react-navigation/native-stack": "^7.3.13", 104 "@sentry/react-native": "~6.20.0", 105 "@tanstack/query-async-storage-persister": "^5.25.0", 106 + "@tanstack/react-query": "5.25.0", 107 "@tanstack/react-query-persist-client": "^5.25.0", 108 "@tiptap/core": "^2.9.1", 109 "@tiptap/extension-document": "^2.9.1",
+39 -39
src/App.native.tsx
··· 25 import {logger} from '#/logger' 26 import {isAndroid, isIOS} from '#/platform/detection' 27 import {Provider as A11yProvider} from '#/state/a11y' 28 - import {Provider as AgeAssuranceProvider} from '#/state/ageAssurance' 29 import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes' 30 import {Provider as DialogStateProvider} from '#/state/dialogs' 31 import {Provider as EmailVerificationProvider} from '#/state/email-verification' 32 import {listenSessionDropped} from '#/state/events' 33 - import { 34 - beginResolveGeolocationConfig, 35 - ensureGeolocationConfigIsResolved, 36 - Provider as GeolocationProvider, 37 - } from '#/state/geolocation' 38 import {GlobalGestureEventsProvider} from '#/state/global-gesture-events' 39 import {Provider as HomeBadgeProvider} from '#/state/home-badge' 40 import {Provider as LightboxStateProvider} from '#/state/lightbox' ··· 56 import {Provider as ShellStateProvider} from '#/state/shell' 57 import {Provider as ComposerProvider} from '#/state/shell/composer' 58 import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out' 59 import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide' 60 import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed' 61 import {Provider as StarterPackProvider} from '#/state/shell/starter-pack' ··· 73 import {Provider as PortalProvider} from '#/components/Portal' 74 import {Provider as VideoVolumeProvider} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext' 75 import {ToastOutlet} from '#/components/Toast' 76 import {Splash} from '#/Splash' 77 import {BottomSheetProvider} from '../modules/bottom-sheet' 78 import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider' ··· 93 /** 94 * Begin geolocation ASAP 95 */ 96 - beginResolveGeolocationConfig() 97 98 function InnerApp() { 99 const [isReady, setIsReady] = React.useState(false) ··· 143 <QueryProvider currentDid={currentAccount?.did}> 144 <PolicyUpdateOverlayProvider> 145 <StatsigProvider> 146 - <AgeAssuranceProvider> 147 <ComposerProvider> 148 <MessagesProvider> 149 {/* LabelDefsProvider MUST come before ModerationOptsProvider */} ··· 186 </LabelDefsProvider> 187 </MessagesProvider> 188 </ComposerProvider> 189 - </AgeAssuranceProvider> 190 </StatsigProvider> 191 </PolicyUpdateOverlayProvider> 192 </QueryProvider> ··· 203 const [isReady, setReady] = useState(false) 204 205 React.useEffect(() => { 206 - Promise.all([ 207 - initPersistedState(), 208 - ensureGeolocationConfigIsResolved(), 209 - ]).then(() => setReady(true)) 210 }, []) 211 212 if (!isReady) { ··· 218 * that is set up in the InnerApp component above. 219 */ 220 return ( 221 - <GeolocationProvider> 222 <A11yProvider> 223 <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> 248 </KeyboardControllerProvider> 249 </A11yProvider> 250 - </GeolocationProvider> 251 ) 252 } 253
··· 25 import {logger} from '#/logger' 26 import {isAndroid, isIOS} from '#/platform/detection' 27 import {Provider as A11yProvider} from '#/state/a11y' 28 import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes' 29 import {Provider as DialogStateProvider} from '#/state/dialogs' 30 import {Provider as EmailVerificationProvider} from '#/state/email-verification' 31 import {listenSessionDropped} from '#/state/events' 32 import {GlobalGestureEventsProvider} from '#/state/global-gesture-events' 33 import {Provider as HomeBadgeProvider} from '#/state/home-badge' 34 import {Provider as LightboxStateProvider} from '#/state/lightbox' ··· 50 import {Provider as ShellStateProvider} from '#/state/shell' 51 import {Provider as ComposerProvider} from '#/state/shell/composer' 52 import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out' 53 + import {Provider as OnboardingProvider} from '#/state/shell/onboarding' 54 import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide' 55 import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed' 56 import {Provider as StarterPackProvider} from '#/state/shell/starter-pack' ··· 68 import {Provider as PortalProvider} from '#/components/Portal' 69 import {Provider as VideoVolumeProvider} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext' 70 import {ToastOutlet} from '#/components/Toast' 71 + import {Provider as AgeAssuranceV2Provider} from '#/ageAssurance' 72 + import {prefetchAgeAssuranceConfig} from '#/ageAssurance' 73 + import * as Geo from '#/geolocation' 74 import {Splash} from '#/Splash' 75 import {BottomSheetProvider} from '../modules/bottom-sheet' 76 import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider' ··· 91 /** 92 * Begin geolocation ASAP 93 */ 94 + Geo.resolve() 95 + prefetchAgeAssuranceConfig() 96 97 function InnerApp() { 98 const [isReady, setIsReady] = React.useState(false) ··· 142 <QueryProvider currentDid={currentAccount?.did}> 143 <PolicyUpdateOverlayProvider> 144 <StatsigProvider> 145 + <AgeAssuranceV2Provider> 146 <ComposerProvider> 147 <MessagesProvider> 148 {/* LabelDefsProvider MUST come before ModerationOptsProvider */} ··· 185 </LabelDefsProvider> 186 </MessagesProvider> 187 </ComposerProvider> 188 + </AgeAssuranceV2Provider> 189 </StatsigProvider> 190 </PolicyUpdateOverlayProvider> 191 </QueryProvider> ··· 202 const [isReady, setReady] = useState(false) 203 204 React.useEffect(() => { 205 + Promise.all([initPersistedState(), Geo.resolve()]).then(() => 206 + setReady(true), 207 + ) 208 }, []) 209 210 if (!isReady) { ··· 216 * that is set up in the InnerApp component above. 217 */ 218 return ( 219 + <Geo.Provider> 220 <A11yProvider> 221 <KeyboardControllerProvider> 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 </KeyboardControllerProvider> 249 </A11yProvider> 250 + </Geo.Provider> 251 ) 252 } 253
+37 -36
src/App.web.tsx
··· 14 import I18nProvider from '#/locale/i18nProvider' 15 import {logger} from '#/logger' 16 import {Provider as A11yProvider} from '#/state/a11y' 17 - import {Provider as AgeAssuranceProvider} from '#/state/ageAssurance' 18 import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes' 19 import {Provider as DialogStateProvider} from '#/state/dialogs' 20 import {Provider as EmailVerificationProvider} from '#/state/email-verification' 21 import {listenSessionDropped} from '#/state/events' 22 - import { 23 - beginResolveGeolocationConfig, 24 - ensureGeolocationConfigIsResolved, 25 - Provider as GeolocationProvider, 26 - } from '#/state/geolocation' 27 import {Provider as HomeBadgeProvider} from '#/state/home-badge' 28 import {Provider as LightboxStateProvider} from '#/state/lightbox' 29 import {MessagesProvider} from '#/state/messages' ··· 44 import {Provider as ShellStateProvider} from '#/state/shell' 45 import {Provider as ComposerProvider} from '#/state/shell/composer' 46 import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out' 47 import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide' 48 import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed' 49 import {Provider as StarterPackProvider} from '#/state/shell/starter-pack' ··· 61 import {Provider as ActiveVideoProvider} from '#/components/Post/Embed/VideoEmbed/ActiveVideoWebContext' 62 import {Provider as VideoVolumeProvider} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext' 63 import {ToastOutlet} from '#/components/Toast' 64 import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider' 65 import {Provider as HideBottomBarBorderProvider} from './lib/hooks/useHideBottomBarBorder' 66 67 /** 68 * Begin geolocation ASAP 69 */ 70 - beginResolveGeolocationConfig() 71 72 function InnerApp() { 73 const [isReady, setIsReady] = React.useState(false) ··· 104 }, [_]) 105 106 // wait for session to resume 107 - if (!isReady || !hasCheckedReferrer) return null 108 109 return ( 110 <Alf theme={theme}> ··· 118 <QueryProvider currentDid={currentAccount?.did}> 119 <PolicyUpdateOverlayProvider> 120 <StatsigProvider> 121 - <AgeAssuranceProvider> 122 <ComposerProvider> 123 <MessagesProvider> 124 {/* LabelDefsProvider MUST come before ModerationOptsProvider */} ··· 157 </LabelDefsProvider> 158 </MessagesProvider> 159 </ComposerProvider> 160 - </AgeAssuranceProvider> 161 </StatsigProvider> 162 </PolicyUpdateOverlayProvider> 163 </QueryProvider> ··· 174 const [isReady, setReady] = useState(false) 175 176 React.useEffect(() => { 177 - Promise.all([ 178 - initPersistedState(), 179 - ensureGeolocationConfigIsResolved(), 180 - ]).then(() => setReady(true)) 181 }, []) 182 183 if (!isReady) { 184 - return null 185 } 186 187 /* ··· 189 * that is set up in the InnerApp component above. 190 */ 191 return ( 192 - <GeolocationProvider> 193 <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> 213 </A11yProvider> 214 - </GeolocationProvider> 215 ) 216 } 217
··· 14 import I18nProvider from '#/locale/i18nProvider' 15 import {logger} from '#/logger' 16 import {Provider as A11yProvider} from '#/state/a11y' 17 import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes' 18 import {Provider as DialogStateProvider} from '#/state/dialogs' 19 import {Provider as EmailVerificationProvider} from '#/state/email-verification' 20 import {listenSessionDropped} from '#/state/events' 21 import {Provider as HomeBadgeProvider} from '#/state/home-badge' 22 import {Provider as LightboxStateProvider} from '#/state/lightbox' 23 import {MessagesProvider} from '#/state/messages' ··· 38 import {Provider as ShellStateProvider} from '#/state/shell' 39 import {Provider as ComposerProvider} from '#/state/shell/composer' 40 import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out' 41 + import {Provider as OnboardingProvider} from '#/state/shell/onboarding' 42 import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide' 43 import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed' 44 import {Provider as StarterPackProvider} from '#/state/shell/starter-pack' ··· 56 import {Provider as ActiveVideoProvider} from '#/components/Post/Embed/VideoEmbed/ActiveVideoWebContext' 57 import {Provider as VideoVolumeProvider} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext' 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' 63 import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider' 64 import {Provider as HideBottomBarBorderProvider} from './lib/hooks/useHideBottomBarBorder' 65 66 /** 67 * Begin geolocation ASAP 68 */ 69 + Geo.resolve() 70 + prefetchAgeAssuranceConfig() 71 72 function InnerApp() { 73 const [isReady, setIsReady] = React.useState(false) ··· 104 }, [_]) 105 106 // wait for session to resume 107 + if (!isReady || !hasCheckedReferrer) return <Splash isReady /> 108 109 return ( 110 <Alf theme={theme}> ··· 118 <QueryProvider currentDid={currentAccount?.did}> 119 <PolicyUpdateOverlayProvider> 120 <StatsigProvider> 121 + <AgeAssuranceV2Provider> 122 <ComposerProvider> 123 <MessagesProvider> 124 {/* LabelDefsProvider MUST come before ModerationOptsProvider */} ··· 157 </LabelDefsProvider> 158 </MessagesProvider> 159 </ComposerProvider> 160 + </AgeAssuranceV2Provider> 161 </StatsigProvider> 162 </PolicyUpdateOverlayProvider> 163 </QueryProvider> ··· 174 const [isReady, setReady] = useState(false) 175 176 React.useEffect(() => { 177 + Promise.all([initPersistedState(), Geo.resolve()]).then(() => 178 + setReady(true), 179 + ) 180 }, []) 181 182 if (!isReady) { 183 + return <Splash isReady /> 184 } 185 186 /* ··· 188 * that is set up in the InnerApp component above. 189 */ 190 return ( 191 + <Geo.Provider> 192 <A11yProvider> 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> 214 </A11yProvider> 215 + </Geo.Provider> 216 ) 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 label, 422 disableUnderline, 423 shouldProxy, 424 ...rest 425 }: Omit< 426 InlineLinkProps, ··· 428 | 'action' 429 | 'disableMismatchWarning' 430 | 'overridePresentation' 431 - | 'onPress' 432 | 'onLongPress' 433 | 'shareOnLongPress' 434 > & { ··· 448 href = createProxiedUrl(href) 449 } 450 451 - const onPress = () => { 452 Linking.openURL(href) 453 } 454 ··· 517 export function createStaticClick( 518 onPressHandler: Exclude<BaseLinkProps['onPress'], undefined>, 519 ): { 520 - to: BaseLinkProps['to'] 521 onPress: Exclude<BaseLinkProps['onPress'], undefined> 522 } { 523 return {
··· 421 label, 422 disableUnderline, 423 shouldProxy, 424 + onPress: outerOnPress, 425 ...rest 426 }: Omit< 427 InlineLinkProps, ··· 429 | 'action' 430 | 'disableMismatchWarning' 431 | 'overridePresentation' 432 | 'onLongPress' 433 | 'shareOnLongPress' 434 > & { ··· 448 href = createProxiedUrl(href) 449 } 450 451 + const onPress = (e: GestureResponderEvent) => { 452 + const exitEarlyIfFalse = outerOnPress?.(e) 453 + if (exitEarlyIfFalse === false) return 454 Linking.openURL(href) 455 } 456 ··· 519 export function createStaticClick( 520 onPressHandler: Exclude<BaseLinkProps['onPress'], undefined>, 521 ): { 522 + to: string 523 onPress: Exclude<BaseLinkProps['onPress'], undefined> 524 } { 525 return {
+3 -3
src/components/PostControls/ShareMenu/ShareMenuItems.tsx
··· 11 import {toShareUrl} from '#/lib/strings/url-helpers' 12 import {logger} from '#/logger' 13 import {isIOS} from '#/platform/detection' 14 - import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance' 15 import {useProfileShadow} from '#/state/cache/profile-shadow' 16 import {useSession} from '#/state/session' 17 import * as Toast from '#/view/com/util/Toast' ··· 24 import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard' 25 import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlaneIcon} from '#/components/icons/PaperPlane' 26 import * as Menu from '#/components/Menu' 27 import {useDevMode} from '#/storage/hooks/dev-mode' 28 import {RecentChats} from './RecentChats' 29 import {type ShareMenuItemsProps} from './ShareMenuItems.types' ··· 37 const navigation = useNavigation<NavigationProp>() 38 const sendViaChatControl = useDialogControl() 39 const [devModeEnabled] = useDevMode() 40 - const {isAgeRestricted} = useAgeAssurance() 41 42 const postUri = post.uri 43 const postAuthor = useProfileShadow(post.author) ··· 91 return ( 92 <> 93 <Menu.Outer> 94 - {hasSession && !isAgeRestricted && ( 95 <Menu.Group> 96 <Menu.ContainerItem> 97 <RecentChats postUri={postUri} />
··· 11 import {toShareUrl} from '#/lib/strings/url-helpers' 12 import {logger} from '#/logger' 13 import {isIOS} from '#/platform/detection' 14 import {useProfileShadow} from '#/state/cache/profile-shadow' 15 import {useSession} from '#/state/session' 16 import * as Toast from '#/view/com/util/Toast' ··· 23 import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard' 24 import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlaneIcon} from '#/components/icons/PaperPlane' 25 import * as Menu from '#/components/Menu' 26 + import {useAgeAssurance} from '#/ageAssurance' 27 import {useDevMode} from '#/storage/hooks/dev-mode' 28 import {RecentChats} from './RecentChats' 29 import {type ShareMenuItemsProps} from './ShareMenuItems.types' ··· 37 const navigation = useNavigation<NavigationProp>() 38 const sendViaChatControl = useDialogControl() 39 const [devModeEnabled] = useDevMode() 40 + const aa = useAgeAssurance() 41 42 const postUri = post.uri 43 const postAuthor = useProfileShadow(post.author) ··· 91 return ( 92 <> 93 <Menu.Outer> 94 + {hasSession && aa.state.access === aa.Access.Full && ( 95 <Menu.Group> 96 <Menu.ContainerItem> 97 <RecentChats postUri={postUri} />
+3 -3
src/components/PostControls/ShareMenu/ShareMenuItems.web.tsx
··· 10 import {toShareUrl} from '#/lib/strings/url-helpers' 11 import {logger} from '#/logger' 12 import {isWeb} from '#/platform/detection' 13 - import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance' 14 import {useProfileShadow} from '#/state/cache/profile-shadow' 15 import {useSession} from '#/state/session' 16 import {useBreakpoints} from '#/alf' ··· 22 import {CodeBrackets_Stroke2_Corner0_Rounded as CodeBracketsIcon} from '#/components/icons/CodeBrackets' 23 import {PaperPlane_Stroke2_Corner0_Rounded as Send} from '#/components/icons/PaperPlane' 24 import * as Menu from '#/components/Menu' 25 import {useDevMode} from '#/storage/hooks/dev-mode' 26 import {type ShareMenuItemsProps} from './ShareMenuItems.types' 27 ··· 38 const embedPostControl = useDialogControl() 39 const sendViaChatControl = useDialogControl() 40 const [devModeEnabled] = useDevMode() 41 - const {isAgeRestricted} = useAgeAssurance() 42 43 const postUri = post.uri 44 const postCid = post.cid ··· 97 <Menu.Outer> 98 {!hideInPWI && copyLinkItem} 99 100 - {hasSession && !isAgeRestricted && ( 101 <Menu.Item 102 testID="postDropdownSendViaDMBtn" 103 label={_(msg`Send via direct message`)}
··· 10 import {toShareUrl} from '#/lib/strings/url-helpers' 11 import {logger} from '#/logger' 12 import {isWeb} from '#/platform/detection' 13 import {useProfileShadow} from '#/state/cache/profile-shadow' 14 import {useSession} from '#/state/session' 15 import {useBreakpoints} from '#/alf' ··· 21 import {CodeBrackets_Stroke2_Corner0_Rounded as CodeBracketsIcon} from '#/components/icons/CodeBrackets' 22 import {PaperPlane_Stroke2_Corner0_Rounded as Send} from '#/components/icons/PaperPlane' 23 import * as Menu from '#/components/Menu' 24 + import {useAgeAssurance} from '#/ageAssurance' 25 import {useDevMode} from '#/storage/hooks/dev-mode' 26 import {type ShareMenuItemsProps} from './ShareMenuItems.types' 27 ··· 38 const embedPostControl = useDialogControl() 39 const sendViaChatControl = useDialogControl() 40 const [devModeEnabled] = useDevMode() 41 + const aa = useAgeAssurance() 42 43 const postUri = post.uri 44 const postCid = post.cid ··· 97 <Menu.Outer> 98 {!hideInPWI && copyLinkItem} 99 100 + {hasSession && aa.state.access === aa.Access.Full && ( 101 <Menu.Item 102 testID="postDropdownSendViaDMBtn" 103 label={_(msg`Send via direct message`)}
+14 -16
src/components/ageAssurance/AgeAssuranceAccountCard.tsx
··· 4 5 import {dateDiff, useGetTimeAgo} from '#/lib/hooks/useTimeAgo' 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 import {atoms as a, useBreakpoints, useTheme, type ViewStyleProp} from '#/alf' 11 import {Admonition} from '#/components/Admonition' 12 import {AgeAssuranceAppealDialog} from '#/components/ageAssurance/AgeAssuranceAppealDialog' ··· 23 import {createStaticClick, InlineLinkText} from '#/components/Link' 24 import * as Toast from '#/components/Toast' 25 import {Text} from '#/components/Typography' 26 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 - 34 return <Inner style={style} /> 35 } 36 ··· 43 const getTimeAgo = useGetTimeAgo() 44 const {gtPhone} = useBreakpoints() 45 const {setDeviceGeolocation} = useDeviceGeolocationApi() 46 47 const copy = useAgeAssuranceCopy() 48 - const {status, lastInitiatedAt} = useAgeAssurance() 49 - const isBlocked = status === 'blocked' 50 const hasInitiated = !!lastInitiatedAt 51 const timeAgo = lastInitiatedAt 52 ? getTimeAgo(lastInitiatedAt, new Date()) ··· 98 <DeviceLocationRequestDialog 99 control={locationControl} 100 onLocationAcquired={props => { 101 - if (props.geolocationStatus.isAgeRestrictedGeo) { 102 props.disableDialogAction() 103 props.setDialogError( 104 _( ··· 108 } else { 109 props.closeDialog(() => { 110 // set this after close! 111 - setDeviceGeolocation({ 112 - countryCode: props.geolocationStatus.countryCode, 113 - regionCode: props.geolocationStatus.regionCode, 114 - }) 115 Toast.show(_(msg`Thanks! You're all set.`), { 116 type: 'success', 117 })
··· 4 5 import {dateDiff, useGetTimeAgo} from '#/lib/hooks/useTimeAgo' 6 import {isNative} from '#/platform/detection' 7 import {atoms as a, useBreakpoints, useTheme, type ViewStyleProp} from '#/alf' 8 import {Admonition} from '#/components/Admonition' 9 import {AgeAssuranceAppealDialog} from '#/components/ageAssurance/AgeAssuranceAppealDialog' ··· 20 import {createStaticClick, InlineLinkText} from '#/components/Link' 21 import * as Toast from '#/components/Toast' 22 import {Text} from '#/components/Typography' 23 + import {logger, useAgeAssurance} from '#/ageAssurance' 24 + import {useComputeAgeAssuranceRegionAccess} from '#/ageAssurance/useComputeAgeAssuranceRegionAccess' 25 + import {useDeviceGeolocationApi} from '#/geolocation' 26 27 export function AgeAssuranceAccountCard({style}: ViewStyleProp & {}) { 28 + const aa = useAgeAssurance() 29 + if (aa.state.access === aa.Access.Full) return null 30 return <Inner style={style} /> 31 } 32 ··· 39 const getTimeAgo = useGetTimeAgo() 40 const {gtPhone} = useBreakpoints() 41 const {setDeviceGeolocation} = useDeviceGeolocationApi() 42 + const computeAgeAssuranceRegionAccess = useComputeAgeAssuranceRegionAccess() 43 44 const copy = useAgeAssuranceCopy() 45 + const aa = useAgeAssurance() 46 + const {status, lastInitiatedAt} = aa.state 47 + const isBlocked = status === aa.Status.Blocked 48 const hasInitiated = !!lastInitiatedAt 49 const timeAgo = lastInitiatedAt 50 ? getTimeAgo(lastInitiatedAt, new Date()) ··· 96 <DeviceLocationRequestDialog 97 control={locationControl} 98 onLocationAcquired={props => { 99 + const access = computeAgeAssuranceRegionAccess( 100 + props.geolocation, 101 + ) 102 + if (access !== aa.Access.Full) { 103 props.disableDialogAction() 104 props.setDialogError( 105 _( ··· 109 } else { 110 props.closeDialog(() => { 111 // set this after close! 112 + setDeviceGeolocation(props.geolocation) 113 Toast.show(_(msg`Thanks! You're all set.`), { 114 type: 'success', 115 })
+4 -6
src/components/ageAssurance/AgeAssuranceAdmonition.tsx
··· 2 import {msg, Trans} from '@lingui/macro' 3 import {useLingui} from '@lingui/react' 4 5 - import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance' 6 - import {logger} from '#/state/ageAssurance/util' 7 import {atoms as a, select, useTheme, type ViewStyleProp} from '#/alf' 8 import {useDialogControl} from '#/components/ageAssurance/AgeAssuranceInitDialog' 9 import type * as Dialog from '#/components/Dialog' 10 import {ShieldCheck_Stroke2_Corner0_Rounded as Shield} from '#/components/icons/Shield' 11 import {InlineLinkText} from '#/components/Link' 12 import {Text} from '#/components/Typography' 13 14 export function AgeAssuranceAdmonition({ 15 children, 16 style, 17 }: ViewStyleProp & {children: React.ReactNode}) { 18 const control = useDialogControl() 19 - const {isReady, isDeclaredUnderage, isAgeRestricted} = useAgeAssurance() 20 21 - if (!isReady) return null 22 - if (isDeclaredUnderage) return null 23 - if (!isAgeRestricted) return null 24 25 return ( 26 <Inner style={style} control={control}>
··· 2 import {msg, Trans} from '@lingui/macro' 3 import {useLingui} from '@lingui/react' 4 5 import {atoms as a, select, useTheme, type ViewStyleProp} from '#/alf' 6 import {useDialogControl} from '#/components/ageAssurance/AgeAssuranceInitDialog' 7 import type * as Dialog from '#/components/Dialog' 8 import {ShieldCheck_Stroke2_Corner0_Rounded as Shield} from '#/components/icons/Shield' 9 import {InlineLinkText} from '#/components/Link' 10 import {Text} from '#/components/Typography' 11 + import {useAgeAssurance} from '#/ageAssurance' 12 + import {logger} from '#/ageAssurance' 13 14 export function AgeAssuranceAdmonition({ 15 children, 16 style, 17 }: ViewStyleProp & {children: React.ReactNode}) { 18 const control = useDialogControl() 19 + const aa = useAgeAssurance() 20 21 + if (aa.state.access === aa.Access.Full) return null 22 23 return ( 24 <Inner style={style} control={control}>
+1 -1
src/components/ageAssurance/AgeAssuranceAppealDialog.tsx
··· 6 import {useMutation} from '@tanstack/react-query' 7 8 import {BLUESKY_MOD_SERVICE_HEADERS} from '#/lib/constants' 9 - import {logger} from '#/state/ageAssurance/util' 10 import {useAgent, useSession} from '#/state/session' 11 import * as Toast from '#/view/com/util/Toast' 12 import {atoms as a, useBreakpoints, web} from '#/alf' ··· 15 import * as Dialog from '#/components/Dialog' 16 import {Loader} from '#/components/Loader' 17 import {Text} from '#/components/Typography' 18 19 export function AgeAssuranceAppealDialog({ 20 control,
··· 6 import {useMutation} from '@tanstack/react-query' 7 8 import {BLUESKY_MOD_SERVICE_HEADERS} from '#/lib/constants' 9 import {useAgent, useSession} from '#/state/session' 10 import * as Toast from '#/view/com/util/Toast' 11 import {atoms as a, useBreakpoints, web} from '#/alf' ··· 14 import * as Dialog from '#/components/Dialog' 15 import {Loader} from '#/components/Loader' 16 import {Text} from '#/components/Typography' 17 + import {logger} from '#/ageAssurance' 18 19 export function AgeAssuranceAppealDialog({ 20 control,
+6 -16
src/components/ageAssurance/AgeAssuranceDismissibleFeedBanner.tsx
··· 3 import {msg} from '@lingui/macro' 4 import {useLingui} from '@lingui/react' 5 6 - import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance' 7 - import {logger} from '#/state/ageAssurance/util' 8 import {Nux, useNux, useSaveNux} from '#/state/queries/nuxs' 9 import {atoms as a, select, useTheme} from '#/alf' 10 import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy' ··· 13 import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' 14 import {Link} from '#/components/Link' 15 import {Text} from '#/components/Typography' 16 17 export function useInternalState() { 18 - const {isReady, isDeclaredUnderage, isAgeRestricted, lastInitiatedAt} = 19 - useAgeAssurance() 20 const {nux} = useNux(Nux.AgeAssuranceDismissibleFeedBanner) 21 const {mutate: save, variables} = useSaveNux() 22 const hidden = !!variables 23 24 const visible = useMemo(() => { 25 - if (!isReady) return false 26 - if (isDeclaredUnderage) return false 27 - if (!isAgeRestricted) return false 28 - if (lastInitiatedAt) return false 29 if (hidden) return false 30 if (nux && nux.completed) return false 31 return true 32 - }, [ 33 - isReady, 34 - isDeclaredUnderage, 35 - isAgeRestricted, 36 - lastInitiatedAt, 37 - hidden, 38 - nux, 39 - ]) 40 41 const close = () => { 42 save({
··· 3 import {msg} from '@lingui/macro' 4 import {useLingui} from '@lingui/react' 5 6 import {Nux, useNux, useSaveNux} from '#/state/queries/nuxs' 7 import {atoms as a, select, useTheme} from '#/alf' 8 import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy' ··· 11 import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' 12 import {Link} from '#/components/Link' 13 import {Text} from '#/components/Typography' 14 + import {useAgeAssurance} from '#/ageAssurance' 15 + import {logger} from '#/ageAssurance' 16 17 export function useInternalState() { 18 + const aa = useAgeAssurance() 19 const {nux} = useNux(Nux.AgeAssuranceDismissibleFeedBanner) 20 const {mutate: save, variables} = useSaveNux() 21 const hidden = !!variables 22 23 const visible = useMemo(() => { 24 + if (aa.state.access === aa.Access.Full) return false 25 + if (aa.state.lastInitiatedAt) return false 26 if (hidden) return false 27 if (nux && nux.completed) return false 28 return true 29 + }, [aa, hidden, nux]) 30 31 const close = () => { 32 save({
+5 -8
src/components/ageAssurance/AgeAssuranceDismissibleNotice.tsx
··· 2 import {msg} from '@lingui/macro' 3 import {useLingui} from '@lingui/react' 4 5 - import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance' 6 - import {logger} from '#/state/ageAssurance/util' 7 import {Nux, useNux, useSaveNux} from '#/state/queries/nuxs' 8 import {atoms as a, type ViewStyleProp} from '#/alf' 9 import {AgeAssuranceAdmonition} from '#/components/ageAssurance/AgeAssuranceAdmonition' 10 import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy' 11 import {Button, ButtonIcon} from '#/components/Button' 12 import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' 13 14 export function AgeAssuranceDismissibleNotice({style}: ViewStyleProp & {}) { 15 const {_} = useLingui() 16 - const {isReady, isDeclaredUnderage, isAgeRestricted, lastInitiatedAt} = 17 - useAgeAssurance() 18 const {nux} = useNux(Nux.AgeAssuranceDismissibleNotice) 19 const copy = useAgeAssuranceCopy() 20 const {mutate: save, variables} = useSaveNux() 21 const hidden = !!variables 22 23 - if (!isReady) return null 24 - if (isDeclaredUnderage) return null 25 - if (!isAgeRestricted) return null 26 - if (lastInitiatedAt) return null 27 if (hidden) return null 28 if (nux && nux.completed) return null 29
··· 2 import {msg} from '@lingui/macro' 3 import {useLingui} from '@lingui/react' 4 5 import {Nux, useNux, useSaveNux} from '#/state/queries/nuxs' 6 import {atoms as a, type ViewStyleProp} from '#/alf' 7 import {AgeAssuranceAdmonition} from '#/components/ageAssurance/AgeAssuranceAdmonition' 8 import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy' 9 import {Button, ButtonIcon} from '#/components/Button' 10 import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' 11 + import {useAgeAssurance} from '#/ageAssurance' 12 + import {logger} from '#/ageAssurance' 13 14 export function AgeAssuranceDismissibleNotice({style}: ViewStyleProp & {}) { 15 const {_} = useLingui() 16 + const aa = useAgeAssurance() 17 const {nux} = useNux(Nux.AgeAssuranceDismissibleNotice) 18 const copy = useAgeAssuranceCopy() 19 const {mutate: save, variables} = useSaveNux() 20 const hidden = !!variables 21 22 + if (aa.state.access === aa.Access.Full) return null 23 + if (aa.state.lastInitiatedAt) return null 24 if (hidden) return null 25 if (nux && nux.completed) return null 26
+16 -21
src/components/ageAssurance/AgeAssuranceInitDialog.tsx
··· 14 import {useTLDs} from '#/lib/hooks/useTLDs' 15 import {isEmailMaybeInvalid} from '#/lib/strings/email' 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 import {useLanguagePrefs} from '#/state/preferences' 21 import {useSession} from '#/state/session' 22 import {atoms as a, useTheme, web} from '#/alf' ··· 30 import * as TextField from '#/components/forms/TextField' 31 import {ShieldCheck_Stroke2_Corner0_Rounded as Shield} from '#/components/icons/Shield' 32 import {LanguageSelect} from '#/components/LanguageSelect' 33 - import {InlineLinkText} from '#/components/Link' 34 import {Loader} from '#/components/Loader' 35 import {Text} from '#/components/Typography' 36 37 export {useDialogControl} from '#/components/Dialog/context' 38 ··· 69 const langPrefs = useLanguagePrefs() 70 const cleanError = useCleanError() 71 const {close} = Dialog.useDialogContext() 72 - const {lastInitiatedAt} = useAgeAssuranceContext() 73 const getTimeAgo = useGetTimeAgo() 74 const tlds = useTLDs() 75 const createSupportLink = useCreateSupportLink() ··· 88 ) 89 const [error, setError] = useState<React.ReactNode>(null) 90 91 - const {mutateAsync: init, isPending} = useInitAgeAssurance() 92 93 const runEmailValidation = () => { 94 if (validateEmail(email)) { ··· 127 return 128 } 129 130 - await init({ 131 email, 132 language, 133 }) ··· 150 <Trans> 151 We're having issues initializing the age assurance process for 152 your account. Please{' '} 153 - <InlineLinkText 154 to={createSupportLink({code: SupportCode.AA_DID, email})} 155 label={_(msg`Contact support`)}> 156 contact support 157 - </InlineLinkText>{' '} 158 for assistance. 159 </Trans> 160 </> ··· 195 <Text style={[a.text_sm, a.leading_snug]}> 196 <Trans> 197 We have partnered with{' '} 198 - <InlineLinkText 199 - overridePresentation 200 - disableMismatchWarning 201 label={_(msg`KWS website`)} 202 to={urls.kwsHome} 203 style={[a.text_sm, a.leading_snug]}> 204 KWS 205 - </InlineLinkText>{' '} 206 to verify that you’re an adult. When you click "Begin" below, 207 KWS will check if you have previously verified your age using 208 this email address for other games/services powered by KWS ··· 328 style={[a.text_xs, a.leading_snug, t.atoms.text_contrast_medium]}> 329 <Trans> 330 By continuing, you agree to the{' '} 331 - <InlineLinkText 332 - overridePresentation 333 - disableMismatchWarning 334 label={_(msg`KWS Terms of Use`)} 335 to={urls.kwsTermsOfUse} 336 style={[a.text_xs, a.leading_snug]}> 337 KWS Terms of Use 338 - </InlineLinkText>{' '} 339 and acknowledge that KWS will store your verified status with 340 your hashed email address in accordance with the{' '} 341 - <InlineLinkText 342 - overridePresentation 343 - disableMismatchWarning 344 label={_(msg`KWS Privacy Policy`)} 345 to={urls.kwsPrivacyPolicy} 346 style={[a.text_xs, a.leading_snug]}> 347 KWS Privacy Policy 348 - </InlineLinkText> 349 . This means you won’t need to verify again the next time you 350 use this email for other apps, games, and services powered by 351 KWS technology.
··· 14 import {useTLDs} from '#/lib/hooks/useTLDs' 15 import {isEmailMaybeInvalid} from '#/lib/strings/email' 16 import {type AppLanguage} from '#/locale/languages' 17 import {useLanguagePrefs} from '#/state/preferences' 18 import {useSession} from '#/state/session' 19 import {atoms as a, useTheme, web} from '#/alf' ··· 27 import * as TextField from '#/components/forms/TextField' 28 import {ShieldCheck_Stroke2_Corner0_Rounded as Shield} from '#/components/icons/Shield' 29 import {LanguageSelect} from '#/components/LanguageSelect' 30 + import {SimpleInlineLinkText} from '#/components/Link' 31 import {Loader} from '#/components/Loader' 32 import {Text} from '#/components/Typography' 33 + import {logger} from '#/ageAssurance' 34 + import {useAgeAssurance} from '#/ageAssurance' 35 + import {useBeginAgeAssurance} from '#/ageAssurance/useBeginAgeAssurance' 36 37 export {useDialogControl} from '#/components/Dialog/context' 38 ··· 69 const langPrefs = useLanguagePrefs() 70 const cleanError = useCleanError() 71 const {close} = Dialog.useDialogContext() 72 + const aa = useAgeAssurance() 73 + const lastInitiatedAt = aa.state.lastInitiatedAt 74 const getTimeAgo = useGetTimeAgo() 75 const tlds = useTLDs() 76 const createSupportLink = useCreateSupportLink() ··· 89 ) 90 const [error, setError] = useState<React.ReactNode>(null) 91 92 + const {mutateAsync: begin, isPending} = useBeginAgeAssurance() 93 94 const runEmailValidation = () => { 95 if (validateEmail(email)) { ··· 128 return 129 } 130 131 + await begin({ 132 email, 133 language, 134 }) ··· 151 <Trans> 152 We're having issues initializing the age assurance process for 153 your account. Please{' '} 154 + <SimpleInlineLinkText 155 to={createSupportLink({code: SupportCode.AA_DID, email})} 156 label={_(msg`Contact support`)}> 157 contact support 158 + </SimpleInlineLinkText>{' '} 159 for assistance. 160 </Trans> 161 </> ··· 196 <Text style={[a.text_sm, a.leading_snug]}> 197 <Trans> 198 We have partnered with{' '} 199 + <SimpleInlineLinkText 200 label={_(msg`KWS website`)} 201 to={urls.kwsHome} 202 style={[a.text_sm, a.leading_snug]}> 203 KWS 204 + </SimpleInlineLinkText>{' '} 205 to verify that you’re an adult. When you click "Begin" below, 206 KWS will check if you have previously verified your age using 207 this email address for other games/services powered by KWS ··· 327 style={[a.text_xs, a.leading_snug, t.atoms.text_contrast_medium]}> 328 <Trans> 329 By continuing, you agree to the{' '} 330 + <SimpleInlineLinkText 331 label={_(msg`KWS Terms of Use`)} 332 to={urls.kwsTermsOfUse} 333 style={[a.text_xs, a.leading_snug]}> 334 KWS Terms of Use 335 + </SimpleInlineLinkText>{' '} 336 and acknowledge that KWS will store your verified status with 337 your hashed email address in accordance with the{' '} 338 + <SimpleInlineLinkText 339 label={_(msg`KWS Privacy Policy`)} 340 to={urls.kwsPrivacyPolicy} 341 style={[a.text_xs, a.leading_snug]}> 342 KWS Privacy Policy 343 + </SimpleInlineLinkText> 344 . This means you won’t need to verify again the next time you 345 use this email for other apps, games, and services powered by 346 KWS technology.
+6 -12
src/components/ageAssurance/AgeAssuranceRedirectDialog.tsx
··· 6 import {retry} from '#/lib/async/retry' 7 import {wait} from '#/lib/async/wait' 8 import {isNative} from '#/platform/detection' 9 - import {useAgeAssuranceAPIContext} from '#/state/ageAssurance' 10 - import {logger} from '#/state/ageAssurance/util' 11 import {useAgent} from '#/state/session' 12 import {atoms as a, useTheme, web} from '#/alf' 13 import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge' ··· 18 import {CircleInfo_Stroke2_Corner0_Rounded as ErrorIcon} from '#/components/icons/CircleInfo' 19 import {Loader} from '#/components/Loader' 20 import {Text} from '#/components/Typography' 21 22 export type AgeAssuranceRedirectDialogState = { 23 result: 'success' | 'unknown' ··· 63 const {_} = useLingui() 64 const control = useAgeAssuranceRedirectDialogControl() 65 66 - // TODO for testing 67 // Dialog.useAutoOpen(control.control, 3e3) 68 69 return ( ··· 88 const control = useAgeAssuranceRedirectDialogControl() 89 const [error, setError] = useState(false) 90 const [success, setSuccess] = useState(false) 91 - const {refetch: refreshAgeAssuranceState} = useAgeAssuranceAPIContext() 92 93 useEffect(() => { 94 if (polling.current) return ··· 106 if (!agent.session) return 107 if (unmounted.current) return 108 109 - const {data} = await agent.app.bsky.unspecced.getAgeAssuranceState() 110 111 - if (data.status !== 'assured') { 112 throw new Error( 113 `Polling for age assurance state did not receive assured status`, 114 ) ··· 123 if (!data) return 124 if (!agent.session) return 125 if (unmounted.current) return 126 - 127 - // success! update state 128 - await refreshAgeAssuranceState() 129 130 setSuccess(true) 131 ··· 134 .catch(() => { 135 if (unmounted.current) return 136 setError(true) 137 - // try a refetch anyway 138 - refreshAgeAssuranceState() 139 logger.metric('ageAssurance:redirectDialogFail', {}) 140 }) 141 142 return () => { 143 unmounted.current = true 144 } 145 - }, [agent, control, refreshAgeAssuranceState]) 146 147 if (success) { 148 return (
··· 6 import {retry} from '#/lib/async/retry' 7 import {wait} from '#/lib/async/wait' 8 import {isNative} from '#/platform/detection' 9 import {useAgent} from '#/state/session' 10 import {atoms as a, useTheme, web} from '#/alf' 11 import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge' ··· 16 import {CircleInfo_Stroke2_Corner0_Rounded as ErrorIcon} from '#/components/icons/CircleInfo' 17 import {Loader} from '#/components/Loader' 18 import {Text} from '#/components/Typography' 19 + import {refetchAgeAssuranceServerState} from '#/ageAssurance' 20 + import {logger} from '#/ageAssurance' 21 22 export type AgeAssuranceRedirectDialogState = { 23 result: 'success' | 'unknown' ··· 63 const {_} = useLingui() 64 const control = useAgeAssuranceRedirectDialogControl() 65 66 + // for testing 67 // Dialog.useAutoOpen(control.control, 3e3) 68 69 return ( ··· 88 const control = useAgeAssuranceRedirectDialogControl() 89 const [error, setError] = useState(false) 90 const [success, setSuccess] = useState(false) 91 92 useEffect(() => { 93 if (polling.current) return ··· 105 if (!agent.session) return 106 if (unmounted.current) return 107 108 + const data = await refetchAgeAssuranceServerState({agent}) 109 110 + if (data?.state.status !== 'assured') { 111 throw new Error( 112 `Polling for age assurance state did not receive assured status`, 113 ) ··· 122 if (!data) return 123 if (!agent.session) return 124 if (unmounted.current) return 125 126 setSuccess(true) 127 ··· 130 .catch(() => { 131 if (unmounted.current) return 132 setError(true) 133 logger.metric('ageAssurance:redirectDialogFail', {}) 134 }) 135 136 return () => { 137 unmounted.current = true 138 } 139 + }, [agent, control]) 140 141 if (success) { 142 return (
+4 -17
src/components/ageAssurance/AgeRestrictedScreen.tsx
··· 2 import {msg, Trans} from '@lingui/macro' 3 import {useLingui} from '@lingui/react' 4 5 - import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance' 6 - import {logger} from '#/state/ageAssurance/util' 7 import {atoms as a} from '#/alf' 8 import {Admonition} from '#/components/Admonition' 9 import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge' ··· 13 import * as Layout from '#/components/Layout' 14 import {Link} from '#/components/Link' 15 import {Text} from '#/components/Typography' 16 17 export function AgeRestrictedScreen({ 18 children, ··· 27 }) { 28 const {_} = useLingui() 29 const copy = useAgeAssuranceCopy() 30 - const {isReady, isAgeRestricted} = useAgeAssurance() 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 46 47 return ( 48 <Layout.Screen>
··· 2 import {msg, Trans} from '@lingui/macro' 3 import {useLingui} from '@lingui/react' 4 5 import {atoms as a} from '#/alf' 6 import {Admonition} from '#/components/Admonition' 7 import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge' ··· 11 import * as Layout from '#/components/Layout' 12 import {Link} from '#/components/Link' 13 import {Text} from '#/components/Typography' 14 + import {useAgeAssurance} from '#/ageAssurance' 15 + import {logger} from '#/ageAssurance' 16 17 export function AgeRestrictedScreen({ 18 children, ··· 27 }) { 28 const {_} = useLingui() 29 const copy = useAgeAssuranceCopy() 30 + const aa = useAgeAssurance() 31 32 + if (aa.state.access === aa.Access.Full) return children 33 34 return ( 35 <Layout.Screen>
+12 -4
src/components/ageAssurance/useAgeAssuranceCopy.ts
··· 2 import {msg} from '@lingui/macro' 3 import {useLingui} from '@lingui/react' 4 5 export function useAgeAssuranceCopy() { 6 const {_} = useLingui() 7 8 return useMemo(() => { 9 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 banner: _( 14 msg`The laws in your location require you to verify you're an adult to access certain features. Tap to learn more.`, 15 ), ··· 17 msg`Don't worry! All existing messages and settings are saved and will be available after you verify you're an adult.`, 18 ), 19 } 20 - }, [_]) 21 }
··· 2 import {msg} from '@lingui/macro' 3 import {useLingui} from '@lingui/react' 4 5 + import {useAgeAssurance} from '#/ageAssurance' 6 + 7 export function useAgeAssuranceCopy() { 8 const {_} = useLingui() 9 + const aa = useAgeAssurance() 10 11 return useMemo(() => { 12 return { 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 + ), 21 banner: _( 22 msg`The laws in your location require you to verify you're an adult to access certain features. Tap to learn more.`, 23 ), ··· 25 msg`Don't worry! All existing messages and settings are saved and will be available after you verify you're an adult.`, 26 ), 27 } 28 + }, [_, aa]) 29 }
+70 -38
src/components/dialogs/BirthDateSettings.tsx
··· 8 import {logger} from '#/logger' 9 import {isIOS, isWeb} from '#/platform/detection' 10 import { 11 usePreferencesQuery, 12 type UsePreferencesQueryResponse, 13 - usePreferencesSetBirthDateMutation, 14 } from '#/state/queries/preferences' 15 import {ErrorMessage} from '#/view/com/util/error/ErrorMessage' 16 import {atoms as a, useTheme, web} from '#/alf' ··· 18 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 19 import * as Dialog from '#/components/Dialog' 20 import {DateField} from '#/components/forms/DateField' 21 - import {InlineLinkText} from '#/components/Link' 22 import {Loader} from '#/components/Loader' 23 import {Text} from '#/components/Typography' 24 ··· 30 const t = useTheme() 31 const {_} = useLingui() 32 const {isLoading, error, data: preferences} = usePreferencesQuery() 33 34 return ( 35 <Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}> 36 <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> 49 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> 66 67 - <Dialog.Close /> 68 - </Dialog.ScrollableInner> 69 </Dialog.Outer> 70 ) 71 } ··· 86 isError, 87 error, 88 mutateAsync: setBirthDate, 89 - } = usePreferencesSetBirthDateMutation() 90 const hasChanged = date !== preferences.birthDate 91 92 const age = getAge(new Date(date)) ··· 112 testID="birthdayInput" 113 value={date} 114 onChangeDate={newDate => setDate(new Date(newDate))} 115 - label={_(msg`Birthday`)} 116 - accessibilityHint={_(msg`Enter your birth date`)} 117 /> 118 </View> 119 ··· 130 <Admonition type="error"> 131 <Trans> 132 You must be at least 13 years old to use Bluesky. Read our{' '} 133 - <InlineLinkText 134 to="https://bsky.social/about/support/tos" 135 label={_(msg`Terms of Service`)}> 136 Terms of Service 137 - </InlineLinkText>{' '} 138 for more information. 139 </Trans> 140 </Admonition> ··· 146 147 <View style={isWeb && [a.flex_row, a.justify_end]}> 148 <Button 149 - label={hasChanged ? _(msg`Save birthday`) : _(msg`Done`)} 150 size="large" 151 onPress={onSave} 152 variant="solid"
··· 8 import {logger} from '#/logger' 9 import {isIOS, isWeb} from '#/platform/detection' 10 import { 11 + useBirthdateMutation, 12 + useIsBirthdateUpdateAllowed, 13 + } from '#/state/birthdate' 14 + import { 15 usePreferencesQuery, 16 type UsePreferencesQueryResponse, 17 } from '#/state/queries/preferences' 18 import {ErrorMessage} from '#/view/com/util/error/ErrorMessage' 19 import {atoms as a, useTheme, web} from '#/alf' ··· 21 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 22 import * as Dialog from '#/components/Dialog' 23 import {DateField} from '#/components/forms/DateField' 24 + import {SimpleInlineLinkText} from '#/components/Link' 25 import {Loader} from '#/components/Loader' 26 import {Text} from '#/components/Typography' 27 ··· 33 const t = useTheme() 34 const {_} = useLingui() 35 const {isLoading, error, data: preferences} = usePreferencesQuery() 36 + const isBirthdateUpdateAllowed = useIsBirthdateUpdateAllowed() 37 38 return ( 39 <Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}> 40 <Dialog.Handle /> 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> 55 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> 72 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 + )} 101 </Dialog.Outer> 102 ) 103 } ··· 118 isError, 119 error, 120 mutateAsync: setBirthDate, 121 + } = useBirthdateMutation() 122 const hasChanged = date !== preferences.birthDate 123 124 const age = getAge(new Date(date)) ··· 144 testID="birthdayInput" 145 value={date} 146 onChangeDate={newDate => setDate(new Date(newDate))} 147 + label={_(msg`Birthdate`)} 148 + accessibilityHint={_(msg`Enter your birthdate`)} 149 /> 150 </View> 151 ··· 162 <Admonition type="error"> 163 <Trans> 164 You must be at least 13 years old to use Bluesky. Read our{' '} 165 + <SimpleInlineLinkText 166 to="https://bsky.social/about/support/tos" 167 label={_(msg`Terms of Service`)}> 168 Terms of Service 169 + </SimpleInlineLinkText>{' '} 170 for more information. 171 </Trans> 172 </Admonition> ··· 178 179 <View style={isWeb && [a.flex_row, a.justify_end]}> 180 <Button 181 + label={hasChanged ? _(msg`Save birthdate`) : _(msg`Done`)} 182 size="large" 183 onPress={onSave} 184 variant="solid"
+4 -11
src/components/dialogs/DeviceLocationRequestDialog.tsx
··· 7 import {isNetworkError, useCleanError} from '#/lib/hooks/useCleanError' 8 import {logger} from '#/logger' 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 import {atoms as a, useTheme, web} from '#/alf' 17 import {Admonition} from '#/components/Admonition' 18 import {Button, ButtonIcon, ButtonText} from '#/components/Button' ··· 20 import {PinLocation_Stroke2_Corner0_Rounded as LocationIcon} from '#/components/icons/PinLocation' 21 import {Loader} from '#/components/Loader' 22 import {Text} from '#/components/Typography' 23 24 export type Props = { 25 onLocationAcquired?: (props: { 26 - geolocationStatus: GeolocationStatus 27 setDialogError: (error: string) => void 28 disableDialogAction: () => void 29 closeDialog: (callback?: () => void) => void ··· 57 const t = useTheme() 58 const {_} = useLingui() 59 const {close} = Dialog.useDialogContext() 60 - const requestDeviceLocation = useRequestDeviceLocation() 61 - const {config} = useGeolocationConfig() 62 const cleanError = useCleanError() 63 64 const [isRequesting, setIsRequesting] = useState(false) ··· 76 const location = req.location 77 78 if (location && location.countryCode) { 79 - const geolocationStatus = computeGeolocationStatus(location, config) 80 onLocationAcquired?.({ 81 - geolocationStatus, 82 setDialogError: setError, 83 disableDialogAction: () => setDialogDisabled(true), 84 closeDialog: close,
··· 7 import {isNetworkError, useCleanError} from '#/lib/hooks/useCleanError' 8 import {logger} from '#/logger' 9 import {isWeb} from '#/platform/detection' 10 import {atoms as a, useTheme, web} from '#/alf' 11 import {Admonition} from '#/components/Admonition' 12 import {Button, ButtonIcon, ButtonText} from '#/components/Button' ··· 14 import {PinLocation_Stroke2_Corner0_Rounded as LocationIcon} from '#/components/icons/PinLocation' 15 import {Loader} from '#/components/Loader' 16 import {Text} from '#/components/Typography' 17 + import {type Geolocation, useRequestDeviceGeolocation} from '#/geolocation' 18 19 export type Props = { 20 onLocationAcquired?: (props: { 21 + geolocation: Geolocation 22 setDialogError: (error: string) => void 23 disableDialogAction: () => void 24 closeDialog: (callback?: () => void) => void ··· 52 const t = useTheme() 53 const {_} = useLingui() 54 const {close} = Dialog.useDialogContext() 55 + const requestDeviceLocation = useRequestDeviceGeolocation() 56 const cleanError = useCleanError() 57 58 const [isRequesting, setIsRequesting] = useState(false) ··· 70 const location = req.location 71 72 if (location && location.countryCode) { 73 onLocationAcquired?.({ 74 + geolocation: location, 75 setDialogError: setError, 76 disableDialogAction: () => setDialogDisabled(true), 77 closeDialog: close,
+5 -8
src/env/common.ts
··· 100 : Number(process.env.EXPO_PUBLIC_GCP_PROJECT_ID) 101 102 /** 103 - * URL for the bapp-config web worker _development_ environment. Can be a 104 * locally running server, see `env.example` for more. 105 */ 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
··· 100 : Number(process.env.EXPO_PUBLIC_GCP_PROJECT_ID) 101 102 /** 103 + * URLs for the app config web worker. Can be a 104 * locally running server, see `env.example` for more. 105 */ 106 export const BAPP_CONFIG_DEV_URL = process.env.BAPP_CONFIG_DEV_URL 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 const res = await agent.resolveHandle({ 164 handle: urip.host, 165 }) 166 urip.host = res.data.did 167 } 168 const res = await agent.getPosts({
··· 163 const res = await agent.resolveHandle({ 164 handle: urip.host, 165 }) 166 + // @ts-expect-error TODO new-sdk-migration 167 urip.host = res.data.did 168 } 169 const res = await agent.getPosts({
+1
src/lib/constants.ts
··· 214 export const PUBLIC_STAGING_APPVIEW_DID = 'did:web:api.staging.bsky.dev' 215 216 export const DEV_ENV_APPVIEW = `http://localhost:2584` // always the same 217 218 // temp hack for e2e - esb 219 export const BLUESKY_PROXY_HEADER = {
··· 214 export const PUBLIC_STAGING_APPVIEW_DID = 'did:web:api.staging.bsky.dev' 215 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 218 219 // temp hack for e2e - esb 220 export const BLUESKY_PROXY_HEADER = {
+2 -2
src/lib/currency.ts
··· 1 import React from 'react' 2 3 import {deviceLocales} from '#/locale/deviceLocales' 4 - import {useGeolocationStatus} from '#/state/geolocation' 5 import {useLanguagePrefs} from '#/state/preferences' 6 7 /** 8 * From react-native-localize ··· 275 export function useFormatCurrency( 276 options?: Parameters<typeof Intl.NumberFormat>[1], 277 ) { 278 - const {location: geolocation} = useGeolocationStatus() 279 const {appLanguage} = useLanguagePrefs() 280 return React.useMemo(() => { 281 const locale = deviceLocales.at(0)
··· 1 import React from 'react' 2 3 import {deviceLocales} from '#/locale/deviceLocales' 4 import {useLanguagePrefs} from '#/state/preferences' 5 + import {useGeolocation} from '#/geolocation' 6 7 /** 8 * From react-native-localize ··· 275 export function useFormatCurrency( 276 options?: Parameters<typeof Intl.NumberFormat>[1], 277 ) { 278 + const geolocation = useGeolocation() 279 const {appLanguage} = useLanguagePrefs() 280 return React.useMemo(() => { 281 const locale = deviceLocales.at(0)
+1 -1
src/lib/hooks/useAccountSwitcher.ts
··· 36 // So we change the URL ourselves. The navigator will pick it up on remount. 37 history.pushState(null, '', '/') 38 } 39 - await resumeSession(account) 40 logEvent('account:loggedIn', {logContext, withPassword: false}) 41 Toast.show(_(msg`Signed in as @${account.handle}`)) 42 } else {
··· 36 // So we change the URL ourselves. The navigator will pick it up on remount. 37 history.pushState(null, '', '/') 38 } 39 + await resumeSession(account, true) 40 logEvent('account:loggedIn', {logContext, withPassword: false}) 41 Toast.show(_(msg`Signed in as @${account.handle}`)) 42 } else {
+1
src/lib/hooks/useCreateSupportLink.ts
··· 9 10 export enum SupportCode { 11 AA_DID = 'AA_DID', 12 } 13 14 /**
··· 9 10 export enum SupportCode { 11 AA_DID = 'AA_DID', 12 + AA_BIRTHDATE = 'AA_BIRTHDATE', 13 } 14 15 /**
+4 -35
src/lib/hooks/useIntentHandler.ts
··· 4 import * as WebBrowser from 'expo-web-browser' 5 6 import {useOpenComposer} from '#/lib/hooks/useOpenComposer' 7 import {logger} from '#/logger' 8 import {isIOS, isNative} from '#/platform/detection' 9 import {useSession} from '#/state/session' 10 import {useCloseAllActiveElements} from '#/state/util' 11 - import { 12 - parseAgeAssuranceRedirectDialogState, 13 - useAgeAssuranceRedirectDialogControl, 14 - } from '#/components/ageAssurance/AgeAssuranceRedirectDialog' 15 import {useIntentDialogs} from '#/components/intents/IntentDialogs' 16 import {Referrer} from '../../../modules/expo-bluesky-swiss-army' 17 import {useApplyPullRequestOTAUpdate} from './useOTAUpdates' ··· 27 const incomingUrl = Linking.useLinkingURL() 28 const composeIntent = useComposeIntent() 29 const verifyEmailIntent = useVerifyEmailIntent() 30 - const ageAssuranceRedirectDialogControl = 31 - useAgeAssuranceRedirectDialogControl() 32 const {currentAccount} = useSession() 33 const {tryApplyUpdate} = useApplyPullRequestOTAUpdate() 34 ··· 47 hostname: referrerInfo?.hostname, 48 }) 49 } 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('/') 61 62 // On native, our links look like bluesky://intent/SomeIntent, so we have to check the hostname for the 63 // intent check. On web, we have to check the first part of the path since we have an actual hostname ··· 82 return 83 } 84 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 - } 102 return 103 } 104 case 'apply-ota': { ··· 127 incomingUrl, 128 composeIntent, 129 verifyEmailIntent, 130 - ageAssuranceRedirectDialogControl, 131 currentAccount, 132 tryApplyUpdate, 133 ])
··· 4 import * as WebBrowser from 'expo-web-browser' 5 6 import {useOpenComposer} from '#/lib/hooks/useOpenComposer' 7 + import {parseLinkingUrl} from '#/lib/parseLinkingUrl' 8 import {logger} from '#/logger' 9 import {isIOS, isNative} from '#/platform/detection' 10 import {useSession} from '#/state/session' 11 import {useCloseAllActiveElements} from '#/state/util' 12 import {useIntentDialogs} from '#/components/intents/IntentDialogs' 13 import {Referrer} from '../../../modules/expo-bluesky-swiss-army' 14 import {useApplyPullRequestOTAUpdate} from './useOTAUpdates' ··· 24 const incomingUrl = Linking.useLinkingURL() 25 const composeIntent = useComposeIntent() 26 const verifyEmailIntent = useVerifyEmailIntent() 27 const {currentAccount} = useSession() 28 const {tryApplyUpdate} = useApplyPullRequestOTAUpdate() 29 ··· 42 hostname: referrerInfo?.hostname, 43 }) 44 } 45 + const urlp = parseLinkingUrl(url) 46 + const [, intent, intentType] = urlp.pathname.split('/') 47 48 // On native, our links look like bluesky://intent/SomeIntent, so we have to check the hostname for the 49 // intent check. On web, we have to check the first part of the path since we have an actual hostname ··· 68 return 69 } 70 case 'age-assurance': { 71 + // Handled in `#/ageAssurance/components/RedirectOverlay.tsx` 72 return 73 } 74 case 'apply-ota': { ··· 97 incomingUrl, 98 composeIntent, 99 verifyEmailIntent, 100 currentAccount, 101 tryApplyUpdate, 102 ])
+12 -15
src/lib/notifications/notifications.ts
··· 9 import {logger as notyLogger} from '#/lib/notifications/util' 10 import {isNetworkError} from '#/lib/strings/errors' 11 import {isNative} from '#/platform/detection' 12 - import {useAgeAssuranceContext} from '#/state/ageAssurance' 13 import {type SessionAccount, useAgent, useSession} from '#/state/session' 14 import BackgroundNotificationHandler from '#/../modules/expo-background-notification-handler' 15 import {IS_DEV} from '#/env' 16 17 /** ··· 125 * @see https://github.com/bluesky-social/social-app/pull/4467 126 */ 127 export function useGetAndRegisterPushToken() { 128 - const {isAgeRestricted} = useAgeAssuranceContext() 129 const registerPushToken = useRegisterPushToken() 130 return useCallback( 131 async ({ ··· 152 */ 153 registerPushToken({ 154 token, 155 - isAgeRestricted: isAgeRestrictedOverride ?? isAgeRestricted, 156 }) 157 } 158 159 return token 160 }, 161 - [registerPushToken, isAgeRestricted], 162 ) 163 } 164 ··· 173 const {currentAccount} = useSession() 174 const registerPushToken = useRegisterPushToken() 175 const getAndRegisterPushToken = useGetAndRegisterPushToken() 176 - const {isReady: isAgeRestrictionReady, isAgeRestricted} = 177 - useAgeAssuranceContext() 178 179 useEffect(() => { 180 /** 181 * We want this to init right away _after_ we have a logged in user, and 182 * _after_ we've loaded their age assurance state. 183 */ 184 - if (!currentAccount || !isAgeRestrictionReady) return 185 186 notyLogger.debug(`useNotificationsRegistration`) 187 ··· 206 * @see https://docs.expo.dev/versions/latest/sdk/notifications/#addpushtokenlistenerlistener 207 */ 208 const subscription = Notifications.addPushTokenListener(async token => { 209 - registerPushToken({token, isAgeRestricted: isAgeRestricted}) 210 notyLogger.debug(`addPushTokenListener callback`, {token}) 211 }) 212 213 return () => { 214 subscription.remove() 215 } 216 - }, [ 217 - currentAccount, 218 - getAndRegisterPushToken, 219 - registerPushToken, 220 - isAgeRestrictionReady, 221 - isAgeRestricted, 222 - ]) 223 } 224 225 export function useRequestNotificationsPermission() {
··· 9 import {logger as notyLogger} from '#/lib/notifications/util' 10 import {isNetworkError} from '#/lib/strings/errors' 11 import {isNative} from '#/platform/detection' 12 import {type SessionAccount, useAgent, useSession} from '#/state/session' 13 import BackgroundNotificationHandler from '#/../modules/expo-background-notification-handler' 14 + import {useAgeAssurance} from '#/ageAssurance' 15 import {IS_DEV} from '#/env' 16 17 /** ··· 125 * @see https://github.com/bluesky-social/social-app/pull/4467 126 */ 127 export function useGetAndRegisterPushToken() { 128 + const aa = useAgeAssurance() 129 const registerPushToken = useRegisterPushToken() 130 return useCallback( 131 async ({ ··· 152 */ 153 registerPushToken({ 154 token, 155 + isAgeRestricted: 156 + isAgeRestrictedOverride ?? aa.state.access !== aa.Access.Full, 157 }) 158 } 159 160 return token 161 }, 162 + [registerPushToken, aa], 163 ) 164 } 165 ··· 174 const {currentAccount} = useSession() 175 const registerPushToken = useRegisterPushToken() 176 const getAndRegisterPushToken = useGetAndRegisterPushToken() 177 + const aa = useAgeAssurance() 178 179 useEffect(() => { 180 /** 181 * We want this to init right away _after_ we have a logged in user, and 182 * _after_ we've loaded their age assurance state. 183 */ 184 + if (!currentAccount) return 185 186 notyLogger.debug(`useNotificationsRegistration`) 187 ··· 206 * @see https://docs.expo.dev/versions/latest/sdk/notifications/#addpushtokenlistenerlistener 207 */ 208 const subscription = Notifications.addPushTokenListener(async token => { 209 + registerPushToken({ 210 + token, 211 + isAgeRestricted: aa.state.access !== aa.Access.Full, 212 + }) 213 notyLogger.debug(`addPushTokenListener callback`, {token}) 214 }) 215 216 return () => { 217 subscription.remove() 218 } 219 + }, [currentAccount, getAndRegisterPushToken, registerPushToken, aa]) 220 } 221 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 collection: string, 42 rkey: string, 43 ) { 44 - const urip = new AtUri('at://host/') 45 urip.host = didOrName 46 urip.collection = collection 47 urip.rkey = rkey
··· 41 collection: string, 42 rkey: string, 43 ) { 44 + const urip = new AtUri('at://placeholder.placeholder/') 45 + // @ts-expect-error TODO new-sdk-migration 46 urip.host = didOrName 47 urip.collection = collection 48 urip.rkey = rkey
+1
src/logger/metrics.ts
··· 22 | 'SignupQueued' 23 | 'Deactivated' 24 | 'Takendown' 25 scope: 'current' | 'every' 26 } 27 'notifications:openApp': {
··· 22 | 'SignupQueued' 23 | 'Deactivated' 24 | 'Takendown' 25 + | 'AgeAssuranceNoAccessScreen' 26 scope: 'current' | 'every' 27 } 28 'notifications:openApp': {
+1 -1
src/screens/Login/ChooseAccountForm.tsx
··· 45 } 46 try { 47 setPendingDid(account.did) 48 - await resumeSession(account) 49 logEvent('account:loggedIn', { 50 logContext: 'ChooseAccountForm', 51 withPassword: false,
··· 45 } 46 try { 47 setPendingDid(account.did) 48 + await resumeSession(account, true) 49 logEvent('account:loggedIn', { 50 logContext: 'ChooseAccountForm', 51 withPassword: false,
+93 -148
src/screens/Moderation/index.tsx
··· 12 } from '#/lib/routes/types' 13 import {logger} from '#/logger' 14 import {isIOS} from '#/platform/detection' 15 - import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance' 16 import { 17 useMyLabelersQuery, 18 usePreferencesQuery, ··· 22 import {isNonConfigurableModerationAuthority} from '#/state/session/additional-moderation-authorities' 23 import {useSetMinimalShellMode} from '#/state/shell' 24 import {atoms as a, useBreakpoints, useTheme, type ViewStyleProp} from '#/alf' 25 - import {Admonition} from '#/components/Admonition' 26 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' 30 import {useGlobalDialogsControlContext} from '#/components/dialogs/Context' 31 import {Divider} from '#/components/Divider' 32 import * as Toggle from '#/components/forms/Toggle' ··· 45 import {Loader} from '#/components/Loader' 46 import {GlobalLabelPreference} from '#/components/moderation/LabelPreference' 47 import {Text} from '#/components/Typography' 48 49 function ErrorState({error}: {error: string}) { 50 const t = useTheme() ··· 86 error: preferencesError, 87 data: preferences, 88 } = usePreferencesQuery() 89 - const {isReady: isAgeInfoReady} = useAgeAssurance() 90 91 - const isLoading = isPreferencesLoading || !isAgeInfoReady 92 const error = preferencesError 93 94 return ( ··· 162 const setMinimalShellMode = useSetMinimalShellMode() 163 const {gtMobile} = useBreakpoints() 164 const {mutedWordsDialogControl} = useGlobalDialogsControlContext() 165 - const birthdateDialogControl = Dialog.useDialogControl() 166 const { 167 isLoading: isLabelersLoading, 168 data: labelers, 169 error: labelersError, 170 } = useMyLabelersQuery() 171 - const {declaredAge, isDeclaredUnderage, isAgeRestricted} = useAgeAssurance() 172 173 useFocusEffect( 174 useCallback(() => { ··· 202 203 return ( 204 <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 <Text 224 style={[ 225 a.text_md, ··· 328 </Link> 329 </View> 330 331 - {(!isDeclaredUnderage || declaredAge === undefined) && ( 332 - <Text 333 style={[ 334 - a.pt_2xl, 335 - a.pb_md, 336 - a.text_md, 337 - a.font_semi_bold, 338 - t.atoms.text_contrast_high, 339 ]}> 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> 396 </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> 414 </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 - )} 435 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 - )} 453 </> 454 )} 455 - </View> 456 - </View> 457 - </> 458 - ) : null} 459 460 <Text 461 style={[
··· 12 } from '#/lib/routes/types' 13 import {logger} from '#/logger' 14 import {isIOS} from '#/platform/detection' 15 import { 16 useMyLabelersQuery, 17 usePreferencesQuery, ··· 21 import {isNonConfigurableModerationAuthority} from '#/state/session/additional-moderation-authorities' 22 import {useSetMinimalShellMode} from '#/state/shell' 23 import {atoms as a, useBreakpoints, useTheme, type ViewStyleProp} from '#/alf' 24 import {AgeAssuranceAdmonition} from '#/components/ageAssurance/AgeAssuranceAdmonition' 25 + import {Button} from '#/components/Button' 26 import {useGlobalDialogsControlContext} from '#/components/dialogs/Context' 27 import {Divider} from '#/components/Divider' 28 import * as Toggle from '#/components/forms/Toggle' ··· 41 import {Loader} from '#/components/Loader' 42 import {GlobalLabelPreference} from '#/components/moderation/LabelPreference' 43 import {Text} from '#/components/Typography' 44 + import {useAgeAssurance} from '#/ageAssurance' 45 46 function ErrorState({error}: {error: string}) { 47 const t = useTheme() ··· 83 error: preferencesError, 84 data: preferences, 85 } = usePreferencesQuery() 86 87 + const isLoading = isPreferencesLoading 88 const error = preferencesError 89 90 return ( ··· 158 const setMinimalShellMode = useSetMinimalShellMode() 159 const {gtMobile} = useBreakpoints() 160 const {mutedWordsDialogControl} = useGlobalDialogsControlContext() 161 const { 162 isLoading: isLabelersLoading, 163 data: labelers, 164 error: labelersError, 165 } = useMyLabelersQuery() 166 + const aa = useAgeAssurance() 167 168 useFocusEffect( 169 useCallback(() => { ··· 197 198 return ( 199 <View style={[a.pt_2xl, a.px_lg, gtMobile && a.px_2xl]}> 200 <Text 201 style={[ 202 a.text_md, ··· 305 </Link> 306 </View> 307 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 327 style={[ 328 + a.w_full, 329 + a.rounded_md, 330 + a.overflow_hidden, 331 + t.atoms.bg_contrast_25, 332 ]}> 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 + )} 360 </Text> 361 + <Toggle.Switch /> 362 </View> 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 + )} 385 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} /> 398 </> 399 )} 400 + </> 401 + )} 402 + </View> 403 + </View> 404 405 <Text 406 style={[
-1
src/screens/Onboarding/StepSuggestedAccounts/index.tsx
··· 312 return ( 313 <View 314 style={[ 315 - a.flex_1, 316 a.w_full, 317 a.py_lg, 318 a.px_xl,
··· 312 return ( 313 <View 314 style={[ 315 a.w_full, 316 a.py_lg, 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 import {FeedTuner, type FeedTunerFn} from '#/lib/api/feed-manip' 32 import {DISCOVER_FEED_URI} from '#/lib/constants' 33 import {logger} from '#/logger' 34 - import {useAgeAssuranceContext} from '#/state/ageAssurance' 35 import {STALE} from '#/state/queries' 36 import {DEFAULT_LOGGED_OUT_PREFERENCES} from '#/state/queries/preferences/const' 37 import {useAgent} from '#/state/session' ··· 141 * available for the remainder of the session, so this delay only affects cold 142 * loads. -esb 143 */ 144 - const {isReady: isAgeAssuranceReady} = useAgeAssuranceContext() 145 const enabled = 146 - opts?.enabled !== false && 147 - Boolean(moderationOpts) && 148 - Boolean(preferences) && 149 - isAgeAssuranceReady 150 const userInterests = aggregateUserInterests(preferences) 151 const followingPinnedIndex = 152 preferences?.savedFeeds?.findIndex(
··· 31 import {FeedTuner, type FeedTunerFn} from '#/lib/api/feed-manip' 32 import {DISCOVER_FEED_URI} from '#/lib/constants' 33 import {logger} from '#/logger' 34 import {STALE} from '#/state/queries' 35 import {DEFAULT_LOGGED_OUT_PREFERENCES} from '#/state/queries/preferences/const' 36 import {useAgent} from '#/state/session' ··· 140 * available for the remainder of the session, so this delay only affects cold 141 * loads. -esb 142 */ 143 const enabled = 144 + opts?.enabled !== false && Boolean(moderationOpts) && Boolean(preferences) 145 const userInterests = aggregateUserInterests(preferences) 146 const followingPinnedIndex = 147 preferences?.savedFeeds?.findIndex(
+2
src/state/queries/post.ts
··· 26 const res = await agent.resolveHandle({ 27 handle: urip.host, 28 }) 29 urip.host = res.data.did 30 } 31 ··· 54 const res = await agent.resolveHandle({ 55 handle: urip.host, 56 }) 57 urip.host = res.data.did 58 } 59
··· 26 const res = await agent.resolveHandle({ 27 handle: urip.host, 28 }) 29 + // @ts-expect-error TODO new-sdk-migration 30 urip.host = res.data.did 31 } 32 ··· 55 const res = await agent.resolveHandle({ 56 handle: urip.host, 57 }) 58 + // @ts-expect-error TODO new-sdk-migration 59 urip.host = res.data.did 60 } 61
+1
src/state/queries/postgate/index.ts
··· 36 const res = await agent.resolveHandle({ 37 handle: urip.host, 38 }) 39 urip.host = res.data.did 40 } 41
··· 36 const res = await agent.resolveHandle({ 37 handle: urip.host, 38 }) 39 + // @ts-expect-error TODO new-sdk-migration 40 urip.host = res.data.did 41 } 42
+9 -24
src/state/queries/preferences/index.ts
··· 10 import {replaceEqualDeep} from '#/lib/functions' 11 import {getAge} from '#/lib/strings/time' 12 import {logger} from '#/logger' 13 - import {useAgeAssuranceContext} from '#/state/ageAssurance' 14 - import {makeAgeRestrictedModerationPrefs} from '#/state/ageAssurance/const' 15 import {STALE} from '#/state/queries' 16 import { 17 DEFAULT_HOME_FEED_PREFS, ··· 24 } from '#/state/queries/preferences/types' 25 import {useAgent} from '#/state/session' 26 import {saveLabelers} from '#/state/session/agent-config' 27 28 export * from '#/state/queries/preferences/const' 29 export * from '#/state/queries/preferences/moderation' ··· 34 35 export function usePreferencesQuery() { 36 const agent = useAgent() 37 - const {isAgeRestricted} = useAgeAssuranceContext() 38 39 return useQuery({ 40 staleTime: STALE.SECONDS.FIFTEEN, ··· 75 }, 76 select: useCallback( 77 (data: UsePreferencesQueryResponse) => { 78 - const isUnderage = (data.userAge || 0) < 18 79 - if (isUnderage || isAgeRestricted) { 80 data = { 81 ...data, 82 - moderationPrefs: makeAgeRestrictedModerationPrefs( 83 - data.moderationPrefs, 84 - ), 85 } 86 } 87 return data 88 }, 89 - [isAgeRestricted], 90 ), 91 }) 92 } ··· 160 return useMutation<void, unknown, {enabled: boolean}>({ 161 mutationFn: async ({enabled}) => { 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 // triggers a refetch 179 await queryClient.invalidateQueries({ 180 queryKey: preferencesQueryKey,
··· 10 import {replaceEqualDeep} from '#/lib/functions' 11 import {getAge} from '#/lib/strings/time' 12 import {logger} from '#/logger' 13 import {STALE} from '#/state/queries' 14 import { 15 DEFAULT_HOME_FEED_PREFS, ··· 22 } from '#/state/queries/preferences/types' 23 import {useAgent} from '#/state/session' 24 import {saveLabelers} from '#/state/session/agent-config' 25 + import {useAgeAssurance} from '#/ageAssurance' 26 27 export * from '#/state/queries/preferences/const' 28 export * from '#/state/queries/preferences/moderation' ··· 33 34 export function usePreferencesQuery() { 35 const agent = useAgent() 36 + const aa = useAgeAssurance() 37 38 return useQuery({ 39 staleTime: STALE.SECONDS.FIFTEEN, ··· 74 }, 75 select: useCallback( 76 (data: UsePreferencesQueryResponse) => { 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) { 82 data = { 83 ...data, 84 + moderationPrefs: DEFAULT_LOGGED_OUT_PREFERENCES.moderationPrefs, 85 } 86 } 87 return data 88 }, 89 + [aa], 90 ), 91 }) 92 } ··· 160 return useMutation<void, unknown, {enabled: boolean}>({ 161 mutationFn: async ({enabled}) => { 162 await agent.setAdultContentEnabled(enabled) 163 // triggers a refetch 164 await queryClient.invalidateQueries({ 165 queryKey: preferencesQueryKey,
+1
src/state/queries/resolve-uri.ts
··· 17 const urip = new AtUri(uri || '') 18 const res = useResolveDidQuery(urip.host) 19 if (res.data) { 20 urip.host = res.data 21 return { 22 ...res,
··· 17 const urip = new AtUri(uri || '') 18 const res = useResolveDidQuery(urip.host) 19 if (res.data) { 20 + // @ts-expect-error TODO new-sdk-migration 21 urip.host = res.data 22 return { 23 ...res,
+1
src/state/queries/threadgate/index.ts
··· 97 const res = await agent.resolveHandle({ 98 handle: urip.host, 99 }) 100 urip.host = res.data.did 101 } 102
··· 97 const res = await agent.resolveHandle({ 98 handle: urip.host, 99 }) 100 + // @ts-expect-error TODO new-sdk-migration 101 urip.host = res.data.did 102 } 103
+3
src/state/session/__tests__/session-test.ts
··· 10 }, 11 })) 12 13 describe('session', () => { 14 it('can log in and out', () => { 15 let state = getInitialState([])
··· 10 }, 11 })) 12 13 + jest.mock('../../birthdate') 14 + jest.mock('../../../ageAssurance/data') 15 + 16 describe('session', () => { 17 it('can log in and out', () => { 18 let state = getInitialState([])
+143 -40
src/state/session/agent.ts
··· 1 import { 2 Agent as BaseAgent, 3 type AtprotoServiceType, 4 type AtpSessionData, 5 type AtpSessionEvent, 6 BskyAgent, 7 type Did, 8 } from '@atproto/api' 9 import {type FetchHandler} from '@atproto/api/dist/agent' 10 import {type SessionManager} from '@atproto/api/dist/session-manager' ··· 23 import {tryFetchGates} from '#/lib/statsig/statsig' 24 import {getAge} from '#/lib/strings/time' 25 import {logger} from '#/logger' 26 import {snoozeEmailConfirmationPrompt} from '#/state/shell/reminders' 27 import {emitNetworkConfirmed, emitNetworkLost} from '../events' 28 import {addSessionErrorLog} from './logging' 29 import { ··· 77 } 78 } 79 80 agent.configureProxy(BLUESKY_PROXY_HEADER.get()) 81 82 - return agent.prepare(gates, moderation, onSessionChange) 83 } 84 85 export async function createAgentAndLogin( ··· 111 const account = agentToSessionAccountOrThrow(agent) 112 const gates = tryFetchGates(account.did, 'prefer-fresh-gates') 113 const moderation = configureModerationForAccount(agent, account) 114 115 agent.configureProxy(BLUESKY_PROXY_HEADER.get()) 116 117 - return agent.prepare(gates, moderation, onSessionChange) 118 } 119 120 export async function createAgentAndCreateAccount( ··· 156 const gates = tryFetchGates(account.did, 'prefer-fresh-gates') 157 const moderation = configureModerationForAccount(agent, account) 158 159 // Not awaited so that we can still get into onboarding. 160 // This is OK because we won't let you toggle adult stuff until you set the date. 161 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', 184 }, 185 }) 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()}) 195 } 196 197 try { ··· 203 204 agent.configureProxy(BLUESKY_PROXY_HEADER.get()) 205 206 - return agent.prepare(gates, moderation, onSessionChange) 207 } 208 209 export function agentToSessionAccountOrThrow(agent: BskyAgent): SessionAccount { ··· 306 }) 307 } 308 309 - async prepare( 310 // Not awaited in the calling code so we can delay blocking on them. 311 - gates: Promise<void>, 312 - moderation: Promise<void>, 313 onSessionChange: ( 314 agent: BskyAgent, 315 did: string, 316 event: AtpSessionEvent, 317 - ) => void, 318 - ) { 319 // There's nothing else left to do, so block on them here. 320 - await Promise.all([gates, moderation]) 321 322 // Now the agent is ready. 323 const account = agentToSessionAccountOrThrow(this)
··· 1 import { 2 Agent as BaseAgent, 3 + type AppBskyActorProfile, 4 type AtprotoServiceType, 5 type AtpSessionData, 6 type AtpSessionEvent, 7 BskyAgent, 8 type Did, 9 + type Un$Typed, 10 } from '@atproto/api' 11 import {type FetchHandler} from '@atproto/api/dist/agent' 12 import {type SessionManager} from '@atproto/api/dist/session-manager' ··· 25 import {tryFetchGates} from '#/lib/statsig/statsig' 26 import {getAge} from '#/lib/strings/time' 27 import {logger} from '#/logger' 28 + import {snoozeBirthdateUpdateAllowedForDid} from '#/state/birthdate' 29 import {snoozeEmailConfirmationPrompt} from '#/state/shell/reminders' 30 + import { 31 + prefetchAgeAssuranceData, 32 + setBirthdateForDid, 33 + setCreatedAtForDid, 34 + } from '#/ageAssurance/data' 35 import {emitNetworkConfirmed, emitNetworkLost} from '../events' 36 import {addSessionErrorLog} from './logging' 37 import { ··· 85 } 86 } 87 88 + // after session is attached 89 + const aa = prefetchAgeAssuranceData({agent}) 90 + 91 agent.configureProxy(BLUESKY_PROXY_HEADER.get()) 92 93 + return agent.prepare({ 94 + resolvers: [gates, moderation, aa], 95 + onSessionChange, 96 + }) 97 } 98 99 export async function createAgentAndLogin( ··· 125 const account = agentToSessionAccountOrThrow(agent) 126 const gates = tryFetchGates(account.did, 'prefer-fresh-gates') 127 const moderation = configureModerationForAccount(agent, account) 128 + const aa = prefetchAgeAssuranceData({agent}) 129 130 agent.configureProxy(BLUESKY_PROXY_HEADER.get()) 131 132 + return agent.prepare({ 133 + resolvers: [gates, moderation, aa], 134 + onSessionChange, 135 + }) 136 } 137 138 export async function createAgentAndCreateAccount( ··· 174 const gates = tryFetchGates(account.did, 'prefer-fresh-gates') 175 const moderation = configureModerationForAccount(agent, account) 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 + 192 // Not awaited so that we can still get into onboarding. 193 // This is OK because we won't let you toggle adult stuff until you set the date. 194 if (IS_PROD_SERVICE(service)) { 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(), 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(), 267 }) 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 + }) 293 } 294 295 try { ··· 301 302 agent.configureProxy(BLUESKY_PROXY_HEADER.get()) 303 304 + return agent.prepare({ 305 + resolvers: [gates, moderation, aa], 306 + onSessionChange, 307 + }) 308 } 309 310 export function agentToSessionAccountOrThrow(agent: BskyAgent): SessionAccount { ··· 407 }) 408 } 409 410 + async prepare({ 411 + resolvers, 412 + onSessionChange, 413 + }: { 414 // Not awaited in the calling code so we can delay blocking on them. 415 + resolvers: Promise<unknown>[] 416 onSessionChange: ( 417 agent: BskyAgent, 418 did: string, 419 event: AtpSessionEvent, 420 + ) => void 421 + }) { 422 // There's nothing else left to do, so block on them here. 423 + await Promise.all(resolvers) 424 425 // Now the agent is ready. 426 const account = agentToSessionAccountOrThrow(this)
+24 -4
src/state/session/index.tsx
··· 24 type SessionApiContext, 25 type SessionStateContext, 26 } from '#/state/session/types' 27 28 const StateContext = React.createContext<SessionStateContext>({ 29 accounts: [], ··· 91 const cancelPendingTask = useOneTaskAtATime() 92 const [store] = React.useState(() => new SessionStore()) 93 const state = React.useSyncExternalStore(store.subscribe, store.getState) 94 95 const onAgentSessionChange = React.useCallback( 96 (agent: BskyAgent, accountDid: string, sessionEvent: AtpSessionEvent) => { ··· 166 logContext => { 167 addSessionDebugLog({type: 'method:start', method: 'logout'}) 168 cancelPendingTask() 169 store.dispatch({ 170 type: 'logged-out-current-account', 171 }) ··· 175 {statsig: true}, 176 ) 177 addSessionDebugLog({type: 'method:end', method: 'logout'}) 178 }, 179 - [store, cancelPendingTask], 180 ) 181 182 const logoutEveryAccount = React.useCallback< ··· 194 {statsig: true}, 195 ) 196 addSessionDebugLog({type: 'method:end', method: 'logout'}) 197 }, 198 - [store, cancelPendingTask], 199 ) 200 201 const resumeSession = React.useCallback<SessionApiContext['resumeSession']>( 202 - async storedAccount => { 203 addSessionDebugLog({ 204 type: 'method:start', 205 method: 'resumeSession', ··· 220 newAccount: account, 221 }) 222 addSessionDebugLog({type: 'method:end', method: 'resumeSession', account}) 223 }, 224 - [store, onAgentSessionChange, cancelPendingTask], 225 ) 226 227 const partialRefreshSession = React.useCallback< ··· 254 accountDid: account.did, 255 }) 256 addSessionDebugLog({type: 'method:end', method: 'removeAccount', account}) 257 }, 258 [store, cancelPendingTask], 259 )
··· 24 type SessionApiContext, 25 type SessionStateContext, 26 } from '#/state/session/types' 27 + import {useOnboardingDispatch} from '#/state/shell/onboarding' 28 + import { 29 + clearAgeAssuranceData, 30 + clearAgeAssuranceDataForDid, 31 + } from '#/ageAssurance/data' 32 33 const StateContext = React.createContext<SessionStateContext>({ 34 accounts: [], ··· 96 const cancelPendingTask = useOneTaskAtATime() 97 const [store] = React.useState(() => new SessionStore()) 98 const state = React.useSyncExternalStore(store.subscribe, store.getState) 99 + const onboardingDispatch = useOnboardingDispatch() 100 101 const onAgentSessionChange = React.useCallback( 102 (agent: BskyAgent, accountDid: string, sessionEvent: AtpSessionEvent) => { ··· 172 logContext => { 173 addSessionDebugLog({type: 'method:start', method: 'logout'}) 174 cancelPendingTask() 175 + const prevState = store.getState() 176 store.dispatch({ 177 type: 'logged-out-current-account', 178 }) ··· 182 {statsig: true}, 183 ) 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'}) 190 }, 191 + [store, cancelPendingTask, onboardingDispatch], 192 ) 193 194 const logoutEveryAccount = React.useCallback< ··· 206 {statsig: true}, 207 ) 208 addSessionDebugLog({type: 'method:end', method: 'logout'}) 209 + clearAgeAssuranceData() 210 + // reset onboarding flow on logout 211 + onboardingDispatch({type: 'skip'}) 212 }, 213 + [store, cancelPendingTask, onboardingDispatch], 214 ) 215 216 const resumeSession = React.useCallback<SessionApiContext['resumeSession']>( 217 + async (storedAccount, isSwitchingAccounts = false) => { 218 addSessionDebugLog({ 219 type: 'method:start', 220 method: 'resumeSession', ··· 235 newAccount: account, 236 }) 237 addSessionDebugLog({type: 'method:end', method: 'resumeSession', account}) 238 + if (isSwitchingAccounts) { 239 + // reset onboarding flow on switch account 240 + onboardingDispatch({type: 'skip'}) 241 + } 242 }, 243 + [store, onAgentSessionChange, cancelPendingTask, onboardingDispatch], 244 ) 245 246 const partialRefreshSession = React.useCallback< ··· 273 accountDid: account.did, 274 }) 275 addSessionDebugLog({type: 'method:end', method: 'removeAccount', account}) 276 + clearAgeAssuranceDataForDid({did: account.did}) 277 }, 278 [store, cancelPendingTask], 279 )
+4 -1
src/state/session/types.ts
··· 38 logoutEveryAccount: ( 39 logContext: LogEvents['account:loggedOut']['logContext'], 40 ) => void 41 - resumeSession: (account: SessionAccount) => Promise<void> 42 removeAccount: (account: SessionAccount) => void 43 /** 44 * Calls `getSession` and updates select fields on the current account and
··· 38 logoutEveryAccount: ( 39 logContext: LogEvents['account:loggedOut']['logContext'], 40 ) => void 41 + resumeSession: ( 42 + account: SessionAccount, 43 + isSwitchingAccounts?: boolean, 44 + ) => Promise<void> 45 removeAccount: (account: SessionAccount) => void 46 /** 47 * Calls `getSession` and updates select fields on the current account and
+1 -4
src/state/shell/index.tsx
··· 2 import {Provider as DrawerOpenProvider} from './drawer-open' 3 import {Provider as DrawerSwipableProvider} from './drawer-swipe-disabled' 4 import {Provider as MinimalModeProvider} from './minimal-mode' 5 - import {Provider as OnboardingProvider} from './onboarding' 6 import {Provider as ShellLayoutProvder} from './shell-layout' 7 import {Provider as TickEveryMinuteProvider} from './tick-every-minute' 8 ··· 23 <DrawerSwipableProvider> 24 <MinimalModeProvider> 25 <ColorModeProvider> 26 - <OnboardingProvider> 27 - <TickEveryMinuteProvider>{children}</TickEveryMinuteProvider> 28 - </OnboardingProvider> 29 </ColorModeProvider> 30 </MinimalModeProvider> 31 </DrawerSwipableProvider>
··· 2 import {Provider as DrawerOpenProvider} from './drawer-open' 3 import {Provider as DrawerSwipableProvider} from './drawer-swipe-disabled' 4 import {Provider as MinimalModeProvider} from './minimal-mode' 5 import {Provider as ShellLayoutProvder} from './shell-layout' 6 import {Provider as TickEveryMinuteProvider} from './tick-every-minute' 7 ··· 22 <DrawerSwipableProvider> 23 <MinimalModeProvider> 24 <ColorModeProvider> 25 + <TickEveryMinuteProvider>{children}</TickEveryMinuteProvider> 26 </ColorModeProvider> 27 </MinimalModeProvider> 28 </DrawerSwipableProvider>
+1
src/state/unstable-post-source.tsx
··· 81 */ 82 export function buildPostSourceKey(key: string, handle: string) { 83 const urip = new AtUri(key) 84 urip.host = handle 85 return urip.toString() 86 }
··· 81 */ 82 export function buildPostSourceKey(key: string, handle: string) { 83 const urip = new AtUri(key) 84 + // @ts-expect-error TODO new-sdk-migration 85 urip.host = handle 86 return urip.toString() 87 }
+19 -4
src/storage/schema.ts
··· 1 import {type ID as PolicyUpdate202508} from '#/components/PolicyUpdateOverlay/updates/202508/config' 2 3 /** 4 * Device data that's specific to the device and does not vary based account ··· 25 regionCode: string | undefined 26 }[] 27 } 28 /** 29 * The GPS-based geolocation, if the user has granted permission. 30 */ 31 - deviceGeolocation?: { 32 - countryCode: string | undefined 33 - regionCode: string | undefined 34 - } 35 36 trendingBetaEnabled: boolean 37 devMode: boolean ··· 49 export type Account = { 50 searchTermHistory?: string[] 51 searchAccountHistory?: string[] 52 }
··· 1 import {type ID as PolicyUpdate202508} from '#/components/PolicyUpdateOverlay/updates/202508/config' 2 + import {type Geolocation} from '#/geolocation/types' 3 4 /** 5 * Device data that's specific to the device and does not vary based account ··· 26 regionCode: string | undefined 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 35 /** 36 * The GPS-based geolocation, if the user has granted permission. 37 */ 38 + deviceGeolocation?: Geolocation 39 + /** 40 + * The merged geolocation, combining `geolocationServiceResponse` and 41 + * `deviceGeolocation`, with preference to `deviceGeolocation`. 42 + */ 43 + mergedGeolocation?: Geolocation 44 45 trendingBetaEnabled: boolean 46 devMode: boolean ··· 58 export type Account = { 59 searchTermHistory?: string[] 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 67 }
+15 -4
src/view/screens/Storybook/index.tsx
··· 3 import {useNavigation} from '@react-navigation/native' 4 5 import {type NavigationProp} from '#/lib/routes/types' 6 - import {Sentry} from '#/logger/sentry/lib' 7 import {useSetThemePrefs} from '#/state/shell' 8 import {ListContained} from '#/view/screens/Storybook/ListContained' 9 import {atoms as a, ThemeProvider} from '#/alf' 10 import {Button, ButtonText} from '#/components/Button' 11 import * as Layout from '#/components/Layout' 12 import {Admonitions} from './Admonitions' 13 import {Breakpoints} from './Breakpoints' 14 import {Buttons} from './Buttons' ··· 45 const {setColorMode, setDarkTheme} = useSetThemePrefs() 46 const [showContainedList, setShowContainedList] = React.useState(false) 47 const navigation = useNavigation<NavigationProp>() 48 49 return ( 50 <> ··· 97 <ButtonText>Open Shared Prefs Tester</ButtonText> 98 </Button> 99 <Button 100 - color="negative" 101 size="large" 102 - onPress={() => Sentry.nativeCrash()} 103 label="crash"> 104 - <ButtonText>Sentry Crash</ButtonText> 105 </Button> 106 107 <ThemeProvider theme="light">
··· 3 import {useNavigation} from '@react-navigation/native' 4 5 import {type NavigationProp} from '#/lib/routes/types' 6 import {useSetThemePrefs} from '#/state/shell' 7 import {ListContained} from '#/view/screens/Storybook/ListContained' 8 import {atoms as a, ThemeProvider} from '#/alf' 9 import {Button, ButtonText} from '#/components/Button' 10 import * as Layout from '#/components/Layout' 11 + import { 12 + useDeviceGeolocationApi, 13 + useRequestDeviceGeolocation, 14 + } from '#/geolocation' 15 import {Admonitions} from './Admonitions' 16 import {Breakpoints} from './Breakpoints' 17 import {Buttons} from './Buttons' ··· 48 const {setColorMode, setDarkTheme} = useSetThemePrefs() 49 const [showContainedList, setShowContainedList] = React.useState(false) 50 const navigation = useNavigation<NavigationProp>() 51 + const requestDeviceGeolocation = useRequestDeviceGeolocation() 52 + const {setDeviceGeolocation} = useDeviceGeolocationApi() 53 54 return ( 55 <> ··· 102 <ButtonText>Open Shared Prefs Tester</ButtonText> 103 </Button> 104 <Button 105 + color="primary_subtle" 106 size="large" 107 + onPress={() => 108 + requestDeviceGeolocation().then(req => { 109 + if (req.granted && req.location) { 110 + setDeviceGeolocation(req.location) 111 + } 112 + }) 113 + } 114 label="crash"> 115 + <ButtonText>Get GPS Location</ButtonText> 116 </Button> 117 118 <ThemeProvider theme="light">
+8 -5
src/view/shell/index.tsx
··· 13 import {isStateAtTabRoot} from '#/lib/routes/helpers' 14 import {isAndroid, isIOS} from '#/platform/detection' 15 import {useDialogFullyExpandedCountContext} from '#/state/dialogs' 16 - import {useGeolocationStatus} from '#/state/geolocation' 17 import {useSession} from '#/state/session' 18 import { 19 useIsDrawerOpen, ··· 27 import {atoms as a, select, useTheme} from '#/alf' 28 import {setSystemUITheme} from '#/alf/util/systemUI' 29 import {AgeAssuranceRedirectDialog} from '#/components/ageAssurance/AgeAssuranceRedirectDialog' 30 - import {BlockedGeoOverlay} from '#/components/BlockedGeoOverlay' 31 import {EmailDialog} from '#/components/dialogs/EmailDialog' 32 import {InAppBrowserConsentDialog} from '#/components/dialogs/InAppBrowserConsent' 33 import {LinkWarningDialog} from '#/components/dialogs/LinkWarning' ··· 38 usePolicyUpdateContext, 39 } from '#/components/PolicyUpdateOverlay' 40 import {Outlet as PortalOutlet} from '#/components/Portal' 41 import {RoutesContainer, TabsNavigator} from '#/Navigation' 42 import {BottomSheetOutlet} from '../../../modules/bottom-sheet' 43 import {updateActiveViewAsync} from '../../../modules/expo-bluesky-swiss-army/src/VisibilityView' ··· 193 194 export function Shell() { 195 const t = useTheme() 196 - const {status: geolocation} = useGeolocationStatus() 197 const fullyExpandedCount = useDialogFullyExpandedCountContext() 198 199 useIntentHandler() ··· 213 navigationBar: t.name !== 'light' ? 'light' : 'dark', 214 }} 215 /> 216 - {geolocation?.isAgeBlockedGeo ? ( 217 - <BlockedGeoOverlay /> 218 ) : ( 219 <RoutesContainer> 220 <ShellInner /> 221 </RoutesContainer> 222 )} 223 </View> 224 ) 225 }
··· 13 import {isStateAtTabRoot} from '#/lib/routes/helpers' 14 import {isAndroid, isIOS} from '#/platform/detection' 15 import {useDialogFullyExpandedCountContext} from '#/state/dialogs' 16 import {useSession} from '#/state/session' 17 import { 18 useIsDrawerOpen, ··· 26 import {atoms as a, select, useTheme} from '#/alf' 27 import {setSystemUITheme} from '#/alf/util/systemUI' 28 import {AgeAssuranceRedirectDialog} from '#/components/ageAssurance/AgeAssuranceRedirectDialog' 29 import {EmailDialog} from '#/components/dialogs/EmailDialog' 30 import {InAppBrowserConsentDialog} from '#/components/dialogs/InAppBrowserConsent' 31 import {LinkWarningDialog} from '#/components/dialogs/LinkWarning' ··· 36 usePolicyUpdateContext, 37 } from '#/components/PolicyUpdateOverlay' 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' 42 import {RoutesContainer, TabsNavigator} from '#/Navigation' 43 import {BottomSheetOutlet} from '../../../modules/bottom-sheet' 44 import {updateActiveViewAsync} from '../../../modules/expo-bluesky-swiss-army/src/VisibilityView' ··· 194 195 export function Shell() { 196 const t = useTheme() 197 + const aa = useAgeAssurance() 198 const fullyExpandedCount = useDialogFullyExpandedCountContext() 199 200 useIntentHandler() ··· 214 navigationBar: t.name !== 'light' ? 'light' : 'dark', 215 }} 216 /> 217 + {aa.state.access === aa.Access.None ? ( 218 + <NoAccessScreen /> 219 ) : ( 220 <RoutesContainer> 221 <ShellInner /> 222 </RoutesContainer> 223 )} 224 + 225 + <RedirectOverlay /> 226 </View> 227 ) 228 }
+8 -5
src/view/shell/index.web.tsx
··· 8 import {useIntentHandler} from '#/lib/hooks/useIntentHandler' 9 import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' 10 import {type NavigationProp} from '#/lib/routes/types' 11 - import {useGeolocationStatus} from '#/state/geolocation' 12 import {useIsDrawerOpen, useSetDrawerOpen} from '#/state/shell' 13 import {useComposerKeyboardShortcut} from '#/state/shell/composer/useComposerKeyboardShortcut' 14 import {useCloseAllActiveElements} from '#/state/util' ··· 17 import {ErrorBoundary} from '#/view/com/util/ErrorBoundary' 18 import {atoms as a, select, useTheme} from '#/alf' 19 import {AgeAssuranceRedirectDialog} from '#/components/ageAssurance/AgeAssuranceRedirectDialog' 20 - import {BlockedGeoOverlay} from '#/components/BlockedGeoOverlay' 21 import {EmailDialog} from '#/components/dialogs/EmailDialog' 22 import {LinkWarningDialog} from '#/components/dialogs/LinkWarning' 23 import {MutedWordsDialog} from '#/components/dialogs/MutedWords' ··· 29 } from '#/components/PolicyUpdateOverlay' 30 import {Outlet as PortalOutlet} from '#/components/Portal' 31 import {WelcomeModal} from '#/components/WelcomeModal' 32 import {FlatNavigator, RoutesContainer} from '#/Navigation' 33 import {Composer} from './Composer.web' 34 import {DrawerContent} from './Drawer' ··· 139 140 export function Shell() { 141 const t = useTheme() 142 - const {status: geolocation} = useGeolocationStatus() 143 return ( 144 <View style={[a.util_screen_outer, t.atoms.bg]}> 145 - {geolocation?.isAgeBlockedGeo ? ( 146 - <BlockedGeoOverlay /> 147 ) : ( 148 <RoutesContainer> 149 <ShellInner /> 150 </RoutesContainer> 151 )} 152 </View> 153 ) 154 }
··· 8 import {useIntentHandler} from '#/lib/hooks/useIntentHandler' 9 import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' 10 import {type NavigationProp} from '#/lib/routes/types' 11 import {useIsDrawerOpen, useSetDrawerOpen} from '#/state/shell' 12 import {useComposerKeyboardShortcut} from '#/state/shell/composer/useComposerKeyboardShortcut' 13 import {useCloseAllActiveElements} from '#/state/util' ··· 16 import {ErrorBoundary} from '#/view/com/util/ErrorBoundary' 17 import {atoms as a, select, useTheme} from '#/alf' 18 import {AgeAssuranceRedirectDialog} from '#/components/ageAssurance/AgeAssuranceRedirectDialog' 19 import {EmailDialog} from '#/components/dialogs/EmailDialog' 20 import {LinkWarningDialog} from '#/components/dialogs/LinkWarning' 21 import {MutedWordsDialog} from '#/components/dialogs/MutedWords' ··· 27 } from '#/components/PolicyUpdateOverlay' 28 import {Outlet as PortalOutlet} from '#/components/Portal' 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' 33 import {FlatNavigator, RoutesContainer} from '#/Navigation' 34 import {Composer} from './Composer.web' 35 import {DrawerContent} from './Drawer' ··· 140 141 export function Shell() { 142 const t = useTheme() 143 + const aa = useAgeAssurance() 144 return ( 145 <View style={[a.util_screen_outer, t.atoms.bg]}> 146 + {aa.state.access === aa.Access.None ? ( 147 + <NoAccessScreen /> 148 ) : ( 149 <RoutesContainer> 150 <ShellInner /> 151 </RoutesContainer> 152 )} 153 + 154 + <RedirectOverlay /> 155 </View> 156 ) 157 }
+12 -4
web/index.html
··· 73 width: 100%; 74 } 75 #splash { 76 position: fixed; 77 width: 100px; 78 - left: 50%; 79 - top: 50%; 80 - transform: translateX(-50%) translateY(-50%) translateY(-50px); 81 } 82 /** 83 * We need these styles to prevent shifting due to scrollbar show/hide on ··· 146 <div id="root"> 147 <div id="splash"> 148 <!-- 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> 150 </div> 151 </div> 152 </body>
··· 73 width: 100%; 74 } 75 #splash { 76 + display: flex; 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; 88 width: 100px; 89 } 90 /** 91 * We need these styles to prevent shifting due to scrollbar show/hide on ··· 154 <div id="root"> 155 <div id="splash"> 156 <!-- Bluesky 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> 158 </div> 159 </div> 160 </body>
+71 -19
yarn.lock
··· 84 tlds "^1.234.0" 85 zod "^3.23.8" 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== 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" 96 await-lock "^2.2.2" 97 multiformats "^9.9.0" 98 tlds "^1.234.0" ··· 192 uint8arrays "3.0.0" 193 zod "^3.23.8" 194 195 "@atproto/common@0.1.0": 196 version "0.1.0" 197 resolved "https://registry.yarnpkg.com/@atproto/common/-/common-0.1.0.tgz#4216a8fef5b985ab62ac21252a0f8ca0f4a0f210" ··· 301 multiformats "^9.9.0" 302 zod "^3.23.8" 303 304 "@atproto/lexicon-resolver@0.2.2", "@atproto/lexicon-resolver@^0.2.2": 305 version "0.2.2" 306 resolved "https://registry.yarnpkg.com/@atproto/lexicon-resolver/-/lexicon-resolver-0.2.2.tgz#2a91a1908f6b327c41cb5c290eb80aed5ef593c0" ··· 320 integrity sha512-y8AEtYmfgVl4fqFxqXAeGvhesiGkxiy3CWoJIfsFDDdTlZUC8DFnZrYhcqkIop3OlCkkljvpSJi1hbeC1tbi8A== 321 dependencies: 322 "@atproto/common-web" "^0.4.3" 323 "@atproto/syntax" "^0.4.1" 324 iso-datestring-validator "^2.2.2" 325 multiformats "^9.9.0" ··· 516 resolved "https://registry.yarnpkg.com/@atproto/syntax/-/syntax-0.4.1.tgz#f77bc610ae0914449ff3f4731861e3da429915f5" 517 integrity sha512-CJdImtLAiFO+0z3BWTtxwk6aY5w4t8orHTMVJgkf++QRJWTxPbIFko/0hrkADB7n2EruDxDSeAgfUGehpH6ngw== 518 519 "@atproto/xrpc-server@^0.9.5": 520 version "0.9.5" 521 resolved "https://registry.yarnpkg.com/@atproto/xrpc-server/-/xrpc-server-0.9.5.tgz#3a036ce2db85bcac40103fd160fef3ed7c364e2b" ··· 540 integrity sha512-MUYNn5d2hv8yVegRL0ccHvTHAVj5JSnW07bkbiaz96UH45lvYNRVwt44z+yYVnb0/mvBzyD3/ZQ55TRGt7fHkA== 541 dependencies: 542 "@atproto/lexicon" "^0.5.1" 543 zod "^3.23.8" 544 545 "@aws-crypto/crc32@5.2.0": ··· 7189 resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.25.0.tgz#e08ed0a9fad34c8005d1a282e57280031ac50cdc" 7190 integrity sha512-vlobHP64HTuSE68lWF1mEhwSRC5Q7gaT+a/m9S+ItuN+ruSOxe1rFnR9j0ACWQ314BPhBEVKfBQ6mHL0OWfdbQ== 7191 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 "@tanstack/query-persist-client-core@5.25.0": 7198 version "5.25.0" 7199 resolved "https://registry.yarnpkg.com/@tanstack/query-persist-client-core/-/query-persist-client-core-5.25.0.tgz#52fa634a8067d7b965854a532a33077fd4df0eff" ··· 7208 dependencies: 7209 "@tanstack/query-persist-client-core" "5.25.0" 7210 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== 7215 dependencies: 7216 - "@tanstack/query-core" "5.8.1" 7217 7218 "@testing-library/jest-native@^5.4.3": 7219 version "5.4.3" ··· 19041 minimist "^1.2.6" 19042 strip-bom "^3.0.0" 19043 19044 - tslib@2, tslib@^2.6.2: 19045 version "2.8.1" 19046 resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" 19047 integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== ··· 19322 version "2.1.0" 19323 resolved "https://registry.yarnpkg.com/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz#43d41e3be698bd493ef911077c9b131f827e8ccd" 19324 integrity sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w== 19325 19326 unimodules-app-loader@~6.0.7: 19327 version "6.0.7"
··· 84 tlds "^1.234.0" 85 zod "^3.23.8" 86 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 dependencies: 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 await-lock "^2.2.2" 97 multiformats "^9.9.0" 98 tlds "^1.234.0" ··· 192 uint8arrays "3.0.0" 193 zod "^3.23.8" 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 + 204 "@atproto/common@0.1.0": 205 version "0.1.0" 206 resolved "https://registry.yarnpkg.com/@atproto/common/-/common-0.1.0.tgz#4216a8fef5b985ab62ac21252a0f8ca0f4a0f210" ··· 310 multiformats "^9.9.0" 311 zod "^3.23.8" 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 + 332 "@atproto/lexicon-resolver@0.2.2", "@atproto/lexicon-resolver@^0.2.2": 333 version "0.2.2" 334 resolved "https://registry.yarnpkg.com/@atproto/lexicon-resolver/-/lexicon-resolver-0.2.2.tgz#2a91a1908f6b327c41cb5c290eb80aed5ef593c0" ··· 348 integrity sha512-y8AEtYmfgVl4fqFxqXAeGvhesiGkxiy3CWoJIfsFDDdTlZUC8DFnZrYhcqkIop3OlCkkljvpSJi1hbeC1tbi8A== 349 dependencies: 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" 362 "@atproto/syntax" "^0.4.1" 363 iso-datestring-validator "^2.2.2" 364 multiformats "^9.9.0" ··· 555 resolved "https://registry.yarnpkg.com/@atproto/syntax/-/syntax-0.4.1.tgz#f77bc610ae0914449ff3f4731861e3da429915f5" 556 integrity sha512-CJdImtLAiFO+0z3BWTtxwk6aY5w4t8orHTMVJgkf++QRJWTxPbIFko/0hrkADB7n2EruDxDSeAgfUGehpH6ngw== 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 + 563 "@atproto/xrpc-server@^0.9.5": 564 version "0.9.5" 565 resolved "https://registry.yarnpkg.com/@atproto/xrpc-server/-/xrpc-server-0.9.5.tgz#3a036ce2db85bcac40103fd160fef3ed7c364e2b" ··· 584 integrity sha512-MUYNn5d2hv8yVegRL0ccHvTHAVj5JSnW07bkbiaz96UH45lvYNRVwt44z+yYVnb0/mvBzyD3/ZQ55TRGt7fHkA== 585 dependencies: 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" 595 zod "^3.23.8" 596 597 "@aws-crypto/crc32@5.2.0": ··· 7241 resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.25.0.tgz#e08ed0a9fad34c8005d1a282e57280031ac50cdc" 7242 integrity sha512-vlobHP64HTuSE68lWF1mEhwSRC5Q7gaT+a/m9S+ItuN+ruSOxe1rFnR9j0ACWQ314BPhBEVKfBQ6mHL0OWfdbQ== 7243 7244 "@tanstack/query-persist-client-core@5.25.0": 7245 version "5.25.0" 7246 resolved "https://registry.yarnpkg.com/@tanstack/query-persist-client-core/-/query-persist-client-core-5.25.0.tgz#52fa634a8067d7b965854a532a33077fd4df0eff" ··· 7255 dependencies: 7256 "@tanstack/query-persist-client-core" "5.25.0" 7257 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== 7262 dependencies: 7263 + "@tanstack/query-core" "5.25.0" 7264 7265 "@testing-library/jest-native@^5.4.3": 7266 version "5.4.3" ··· 19088 minimist "^1.2.6" 19089 strip-bom "^3.0.0" 19090 19091 + tslib@2, tslib@^2.6.2, tslib@^2.8.1: 19092 version "2.8.1" 19093 resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" 19094 integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== ··· 19369 version "2.1.0" 19370 resolved "https://registry.yarnpkg.com/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz#43d41e3be698bd493ef911077c9b131f827e8ccd" 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== 19377 19378 unimodules-app-loader@~6.0.7: 19379 version "6.0.7"