Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f4f0a487e7 | |||
| 3dee9e75f9 | |||
| c4aef9f668 | |||
| 7735183af4 | |||
| f0c9e1ea5e | |||
| 4d1b1bb2fd | |||
| 507531e1db | |||
| 0ba4edab8d | |||
| 1eacf427b5 |
@@ -36,6 +36,3 @@ EXPO_PUBLIC_BITDRIFT_API_KEY=
|
||||
|
||||
# bapp-config web worker URL
|
||||
BAPP_CONFIG_DEV_URL=
|
||||
|
||||
# Dev-only passthrough value for bapp-config web worker
|
||||
BAPP_CONFIG_DEV_BYPASS_SECRET=
|
||||
|
||||
@@ -957,20 +957,20 @@ describe('parseStarterPackHttpUri', () => {
|
||||
})
|
||||
|
||||
it('returns the at uri when the input is a valid starterpack at uri', () => {
|
||||
const validAtUri = 'at://did:123/app.bsky.graph.starterpack/rkey'
|
||||
const validAtUri = 'at://did:plc:123/app.bsky.graph.starterpack/rkey'
|
||||
expect(parseStarterPackUri(validAtUri)).toEqual({
|
||||
name: 'did:123',
|
||||
name: 'did:plc:123',
|
||||
rkey: 'rkey',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null when the at uri has no rkey', () => {
|
||||
const validAtUri = 'at://did:123/app.bsky.graph.starterpack'
|
||||
const validAtUri = 'at://did:plc:123/app.bsky.graph.starterpack'
|
||||
expect(parseStarterPackUri(validAtUri)).toEqual(null)
|
||||
})
|
||||
|
||||
it('returns null when the collection is not app.bsky.graph.starterpack', () => {
|
||||
const validAtUri = 'at://did:123/app.bsky.graph.list/rkey'
|
||||
const validAtUri = 'at://did:plc:123/app.bsky.graph.list/rkey'
|
||||
expect(parseStarterPackUri(validAtUri)).toEqual(null)
|
||||
})
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ func (r Renderer) Render(w io.Writer, name string, data interface{}, c echo.Cont
|
||||
if r.Debug {
|
||||
t, err = pongo2.FromFile(name)
|
||||
} else {
|
||||
t, err = r.TemplateSet.FromFile(name)
|
||||
t, err = r.TemplateSet.FromCache(name)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
|
||||
@@ -68,11 +68,19 @@
|
||||
width: 100%;
|
||||
}
|
||||
#splash {
|
||||
display: flex;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
#splash svg {
|
||||
position: relative;
|
||||
top: -50px;
|
||||
width: 100px;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translateX(-50%) translateY(-50%) translateY(-50px);
|
||||
}
|
||||
/**
|
||||
* We need these styles to prevent shifting due to scrollbar show/hide on
|
||||
@@ -106,7 +114,7 @@
|
||||
<div id="root">
|
||||
<div id="splash">
|
||||
<!-- Bluesky SVG -->
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
+2
-3
@@ -72,7 +72,7 @@
|
||||
"icons:optimize": "svgo -f ./assets/icons"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/api": "^0.18.0",
|
||||
"@atproto/api": "^0.18.4",
|
||||
"@bitdrift/react-native": "^0.6.8",
|
||||
"@braintree/sanitize-url": "^6.0.2",
|
||||
"@bsky.app/alf": "^0.1.5",
|
||||
@@ -103,7 +103,7 @@
|
||||
"@react-navigation/native-stack": "^7.3.13",
|
||||
"@sentry/react-native": "~6.20.0",
|
||||
"@tanstack/query-async-storage-persister": "^5.25.0",
|
||||
"@tanstack/react-query": "^5.8.1",
|
||||
"@tanstack/react-query": "5.25.0",
|
||||
"@tanstack/react-query-persist-client": "^5.25.0",
|
||||
"@tiptap/core": "^2.9.1",
|
||||
"@tiptap/extension-document": "^2.9.1",
|
||||
@@ -173,7 +173,6 @@
|
||||
"lodash.isequal": "^4.5.0",
|
||||
"lodash.shuffle": "^4.2.0",
|
||||
"lodash.throttle": "^4.1.1",
|
||||
"mediabunny": "^1.25.3",
|
||||
"multiformats": "9.9.0",
|
||||
"nanoid": "^5.0.5",
|
||||
"normalize-url": "^8.0.0",
|
||||
|
||||
+39
-39
@@ -25,16 +25,10 @@ import I18nProvider from '#/locale/i18nProvider'
|
||||
import {logger} from '#/logger'
|
||||
import {isAndroid, isIOS} from '#/platform/detection'
|
||||
import {Provider as A11yProvider} from '#/state/a11y'
|
||||
import {Provider as AgeAssuranceProvider} from '#/state/ageAssurance'
|
||||
import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes'
|
||||
import {Provider as DialogStateProvider} from '#/state/dialogs'
|
||||
import {Provider as EmailVerificationProvider} from '#/state/email-verification'
|
||||
import {listenSessionDropped} from '#/state/events'
|
||||
import {
|
||||
beginResolveGeolocationConfig,
|
||||
ensureGeolocationConfigIsResolved,
|
||||
Provider as GeolocationProvider,
|
||||
} from '#/state/geolocation'
|
||||
import {GlobalGestureEventsProvider} from '#/state/global-gesture-events'
|
||||
import {Provider as HomeBadgeProvider} from '#/state/home-badge'
|
||||
import {Provider as LightboxStateProvider} from '#/state/lightbox'
|
||||
@@ -56,6 +50,7 @@ import {readLastActiveAccount} from '#/state/session/util'
|
||||
import {Provider as ShellStateProvider} from '#/state/shell'
|
||||
import {Provider as ComposerProvider} from '#/state/shell/composer'
|
||||
import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
|
||||
import {Provider as OnboardingProvider} from '#/state/shell/onboarding'
|
||||
import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
|
||||
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
|
||||
import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
|
||||
@@ -73,6 +68,9 @@ import {Provider as PolicyUpdateOverlayProvider} from '#/components/PolicyUpdate
|
||||
import {Provider as PortalProvider} from '#/components/Portal'
|
||||
import {Provider as VideoVolumeProvider} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext'
|
||||
import {ToastOutlet} from '#/components/Toast'
|
||||
import {Provider as AgeAssuranceV2Provider} from '#/ageAssurance'
|
||||
import {prefetchAgeAssuranceConfig} from '#/ageAssurance'
|
||||
import * as Geo from '#/geolocation'
|
||||
import {Splash} from '#/Splash'
|
||||
import {BottomSheetProvider} from '../modules/bottom-sheet'
|
||||
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
|
||||
@@ -93,7 +91,8 @@ if (isAndroid) {
|
||||
/**
|
||||
* Begin geolocation ASAP
|
||||
*/
|
||||
beginResolveGeolocationConfig()
|
||||
Geo.resolve()
|
||||
prefetchAgeAssuranceConfig()
|
||||
|
||||
function InnerApp() {
|
||||
const [isReady, setIsReady] = React.useState(false)
|
||||
@@ -143,7 +142,7 @@ function InnerApp() {
|
||||
<QueryProvider currentDid={currentAccount?.did}>
|
||||
<PolicyUpdateOverlayProvider>
|
||||
<StatsigProvider>
|
||||
<AgeAssuranceProvider>
|
||||
<AgeAssuranceV2Provider>
|
||||
<ComposerProvider>
|
||||
<MessagesProvider>
|
||||
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
|
||||
@@ -186,7 +185,7 @@ function InnerApp() {
|
||||
</LabelDefsProvider>
|
||||
</MessagesProvider>
|
||||
</ComposerProvider>
|
||||
</AgeAssuranceProvider>
|
||||
</AgeAssuranceV2Provider>
|
||||
</StatsigProvider>
|
||||
</PolicyUpdateOverlayProvider>
|
||||
</QueryProvider>
|
||||
@@ -203,10 +202,9 @@ function App() {
|
||||
const [isReady, setReady] = useState(false)
|
||||
|
||||
React.useEffect(() => {
|
||||
Promise.all([
|
||||
initPersistedState(),
|
||||
ensureGeolocationConfigIsResolved(),
|
||||
]).then(() => setReady(true))
|
||||
Promise.all([initPersistedState(), Geo.resolve()]).then(() =>
|
||||
setReady(true),
|
||||
)
|
||||
}, [])
|
||||
|
||||
if (!isReady) {
|
||||
@@ -218,36 +216,38 @@ function App() {
|
||||
* that is set up in the InnerApp component above.
|
||||
*/
|
||||
return (
|
||||
<GeolocationProvider>
|
||||
<Geo.Provider>
|
||||
<A11yProvider>
|
||||
<KeyboardControllerProvider>
|
||||
<SessionProvider>
|
||||
<PrefsStateProvider>
|
||||
<I18nProvider>
|
||||
<ShellStateProvider>
|
||||
<ModalStateProvider>
|
||||
<DialogStateProvider>
|
||||
<LightboxStateProvider>
|
||||
<PortalProvider>
|
||||
<BottomSheetProvider>
|
||||
<StarterPackProvider>
|
||||
<SafeAreaProvider
|
||||
initialMetrics={initialWindowMetrics}>
|
||||
<InnerApp />
|
||||
</SafeAreaProvider>
|
||||
</StarterPackProvider>
|
||||
</BottomSheetProvider>
|
||||
</PortalProvider>
|
||||
</LightboxStateProvider>
|
||||
</DialogStateProvider>
|
||||
</ModalStateProvider>
|
||||
</ShellStateProvider>
|
||||
</I18nProvider>
|
||||
</PrefsStateProvider>
|
||||
</SessionProvider>
|
||||
<OnboardingProvider>
|
||||
<SessionProvider>
|
||||
<PrefsStateProvider>
|
||||
<I18nProvider>
|
||||
<ShellStateProvider>
|
||||
<ModalStateProvider>
|
||||
<DialogStateProvider>
|
||||
<LightboxStateProvider>
|
||||
<PortalProvider>
|
||||
<BottomSheetProvider>
|
||||
<StarterPackProvider>
|
||||
<SafeAreaProvider
|
||||
initialMetrics={initialWindowMetrics}>
|
||||
<InnerApp />
|
||||
</SafeAreaProvider>
|
||||
</StarterPackProvider>
|
||||
</BottomSheetProvider>
|
||||
</PortalProvider>
|
||||
</LightboxStateProvider>
|
||||
</DialogStateProvider>
|
||||
</ModalStateProvider>
|
||||
</ShellStateProvider>
|
||||
</I18nProvider>
|
||||
</PrefsStateProvider>
|
||||
</SessionProvider>
|
||||
</OnboardingProvider>
|
||||
</KeyboardControllerProvider>
|
||||
</A11yProvider>
|
||||
</GeolocationProvider>
|
||||
</Geo.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+37
-36
@@ -14,16 +14,10 @@ import {ThemeProvider} from '#/lib/ThemeContext'
|
||||
import I18nProvider from '#/locale/i18nProvider'
|
||||
import {logger} from '#/logger'
|
||||
import {Provider as A11yProvider} from '#/state/a11y'
|
||||
import {Provider as AgeAssuranceProvider} from '#/state/ageAssurance'
|
||||
import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes'
|
||||
import {Provider as DialogStateProvider} from '#/state/dialogs'
|
||||
import {Provider as EmailVerificationProvider} from '#/state/email-verification'
|
||||
import {listenSessionDropped} from '#/state/events'
|
||||
import {
|
||||
beginResolveGeolocationConfig,
|
||||
ensureGeolocationConfigIsResolved,
|
||||
Provider as GeolocationProvider,
|
||||
} from '#/state/geolocation'
|
||||
import {Provider as HomeBadgeProvider} from '#/state/home-badge'
|
||||
import {Provider as LightboxStateProvider} from '#/state/lightbox'
|
||||
import {MessagesProvider} from '#/state/messages'
|
||||
@@ -44,6 +38,7 @@ import {readLastActiveAccount} from '#/state/session/util'
|
||||
import {Provider as ShellStateProvider} from '#/state/shell'
|
||||
import {Provider as ComposerProvider} from '#/state/shell/composer'
|
||||
import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
|
||||
import {Provider as OnboardingProvider} from '#/state/shell/onboarding'
|
||||
import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
|
||||
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
|
||||
import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
|
||||
@@ -61,13 +56,18 @@ import {Provider as PortalProvider} from '#/components/Portal'
|
||||
import {Provider as ActiveVideoProvider} from '#/components/Post/Embed/VideoEmbed/ActiveVideoWebContext'
|
||||
import {Provider as VideoVolumeProvider} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext'
|
||||
import {ToastOutlet} from '#/components/Toast'
|
||||
import {Provider as AgeAssuranceV2Provider} from '#/ageAssurance'
|
||||
import {prefetchAgeAssuranceConfig} from '#/ageAssurance'
|
||||
import * as Geo from '#/geolocation'
|
||||
import {Splash} from '#/Splash'
|
||||
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
|
||||
import {Provider as HideBottomBarBorderProvider} from './lib/hooks/useHideBottomBarBorder'
|
||||
|
||||
/**
|
||||
* Begin geolocation ASAP
|
||||
*/
|
||||
beginResolveGeolocationConfig()
|
||||
Geo.resolve()
|
||||
prefetchAgeAssuranceConfig()
|
||||
|
||||
function InnerApp() {
|
||||
const [isReady, setIsReady] = React.useState(false)
|
||||
@@ -104,7 +104,7 @@ function InnerApp() {
|
||||
}, [_])
|
||||
|
||||
// wait for session to resume
|
||||
if (!isReady || !hasCheckedReferrer) return null
|
||||
if (!isReady || !hasCheckedReferrer) return <Splash isReady />
|
||||
|
||||
return (
|
||||
<Alf theme={theme}>
|
||||
@@ -118,7 +118,7 @@ function InnerApp() {
|
||||
<QueryProvider currentDid={currentAccount?.did}>
|
||||
<PolicyUpdateOverlayProvider>
|
||||
<StatsigProvider>
|
||||
<AgeAssuranceProvider>
|
||||
<AgeAssuranceV2Provider>
|
||||
<ComposerProvider>
|
||||
<MessagesProvider>
|
||||
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
|
||||
@@ -157,7 +157,7 @@ function InnerApp() {
|
||||
</LabelDefsProvider>
|
||||
</MessagesProvider>
|
||||
</ComposerProvider>
|
||||
</AgeAssuranceProvider>
|
||||
</AgeAssuranceV2Provider>
|
||||
</StatsigProvider>
|
||||
</PolicyUpdateOverlayProvider>
|
||||
</QueryProvider>
|
||||
@@ -174,14 +174,13 @@ function App() {
|
||||
const [isReady, setReady] = useState(false)
|
||||
|
||||
React.useEffect(() => {
|
||||
Promise.all([
|
||||
initPersistedState(),
|
||||
ensureGeolocationConfigIsResolved(),
|
||||
]).then(() => setReady(true))
|
||||
Promise.all([initPersistedState(), Geo.resolve()]).then(() =>
|
||||
setReady(true),
|
||||
)
|
||||
}, [])
|
||||
|
||||
if (!isReady) {
|
||||
return null
|
||||
return <Splash isReady />
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -189,29 +188,31 @@ function App() {
|
||||
* that is set up in the InnerApp component above.
|
||||
*/
|
||||
return (
|
||||
<GeolocationProvider>
|
||||
<Geo.Provider>
|
||||
<A11yProvider>
|
||||
<SessionProvider>
|
||||
<PrefsStateProvider>
|
||||
<I18nProvider>
|
||||
<ShellStateProvider>
|
||||
<ModalStateProvider>
|
||||
<DialogStateProvider>
|
||||
<LightboxStateProvider>
|
||||
<PortalProvider>
|
||||
<StarterPackProvider>
|
||||
<InnerApp />
|
||||
</StarterPackProvider>
|
||||
</PortalProvider>
|
||||
</LightboxStateProvider>
|
||||
</DialogStateProvider>
|
||||
</ModalStateProvider>
|
||||
</ShellStateProvider>
|
||||
</I18nProvider>
|
||||
</PrefsStateProvider>
|
||||
</SessionProvider>
|
||||
<OnboardingProvider>
|
||||
<SessionProvider>
|
||||
<PrefsStateProvider>
|
||||
<I18nProvider>
|
||||
<ShellStateProvider>
|
||||
<ModalStateProvider>
|
||||
<DialogStateProvider>
|
||||
<LightboxStateProvider>
|
||||
<PortalProvider>
|
||||
<StarterPackProvider>
|
||||
<InnerApp />
|
||||
</StarterPackProvider>
|
||||
</PortalProvider>
|
||||
</LightboxStateProvider>
|
||||
</DialogStateProvider>
|
||||
</ModalStateProvider>
|
||||
</ShellStateProvider>
|
||||
</I18nProvider>
|
||||
</PrefsStateProvider>
|
||||
</SessionProvider>
|
||||
</OnboardingProvider>
|
||||
</A11yProvider>
|
||||
</GeolocationProvider>
|
||||
</Geo.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* This is a reimplementation of what exists in our HTML template files
|
||||
* already. Once the React tree mounts, this is what gets rendered first, until
|
||||
* the app is ready to go.
|
||||
*/
|
||||
|
||||
import {View} from 'react-native'
|
||||
import Svg, {Path} from 'react-native-svg'
|
||||
|
||||
import {atoms as a} from '#/alf'
|
||||
|
||||
const size = 100
|
||||
const ratio = 57 / 64
|
||||
|
||||
export function Splash() {
|
||||
return (
|
||||
<View style={[a.fixed, a.inset_0, a.align_center, a.justify_center]}>
|
||||
<Svg
|
||||
fill="none"
|
||||
viewBox="0 0 64 57"
|
||||
style={[a.relative, {width: size, height: size * ratio, top: -50}]}>
|
||||
<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>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export const prefetchAgeAssuranceData = () => {}
|
||||
export const setBirthdateForDid = () => {}
|
||||
export const setCreatedAtForDid = () => {}
|
||||
@@ -0,0 +1,355 @@
|
||||
import {useCallback, useEffect} from 'react'
|
||||
import {ScrollView, View} from 'react-native'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {
|
||||
SupportCode,
|
||||
useCreateSupportLink,
|
||||
} from '#/lib/hooks/useCreateSupportLink'
|
||||
import {dateDiff, useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
|
||||
import {logger} from '#/logger'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {useIsBirthdateUpdateAllowed} from '#/state/birthdate'
|
||||
import {useSessionApi} from '#/state/session'
|
||||
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
|
||||
import {Admonition} from '#/components/Admonition'
|
||||
import {AgeAssuranceAppealDialog} from '#/components/ageAssurance/AgeAssuranceAppealDialog'
|
||||
import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge'
|
||||
import {AgeAssuranceInitDialog} from '#/components/ageAssurance/AgeAssuranceInitDialog'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import {useDialogControl} from '#/components/Dialog'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings'
|
||||
import {DeviceLocationRequestDialog} from '#/components/dialogs/DeviceLocationRequestDialog'
|
||||
import {Full as Logo} from '#/components/icons/Logo'
|
||||
import {ShieldCheck_Stroke2_Corner0_Rounded as ShieldIcon} from '#/components/icons/Shield'
|
||||
import {createStaticClick, SimpleInlineLinkText} from '#/components/Link'
|
||||
import {Outlet as PortalOutlet} from '#/components/Portal'
|
||||
import * as Toast from '#/components/Toast'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {BottomSheetOutlet} from '#/../modules/bottom-sheet'
|
||||
import {useAgeAssurance} from '#/ageAssurance'
|
||||
import {useAgeAssuranceDataContext} from '#/ageAssurance/data'
|
||||
import {useComputeAgeAssuranceRegionAccess} from '#/ageAssurance/useComputeAgeAssuranceRegionAccess'
|
||||
import {
|
||||
isLegacyBirthdateBug,
|
||||
useAgeAssuranceRegionConfig,
|
||||
} from '#/ageAssurance/util'
|
||||
import {useDeviceGeolocationApi} from '#/geolocation'
|
||||
|
||||
const textStyles = [a.text_md, a.leading_snug]
|
||||
|
||||
export function NoAccessScreen() {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {gtPhone} = useBreakpoints()
|
||||
const insets = useSafeAreaInsets()
|
||||
const birthdateControl = useDialogControl()
|
||||
const {data} = useAgeAssuranceDataContext()
|
||||
const region = useAgeAssuranceRegionConfig()
|
||||
const isBirthdateUpdateAllowed = useIsBirthdateUpdateAllowed()
|
||||
const {logoutCurrentAccount} = useSessionApi()
|
||||
const createSupportLink = useCreateSupportLink()
|
||||
|
||||
const aa = useAgeAssurance()
|
||||
const isBlocked = aa.state.status === aa.Status.Blocked
|
||||
const isAARegion = !!region
|
||||
const hasDeclaredAge = data?.declaredAge !== undefined
|
||||
const canUpdateBirthday =
|
||||
isBirthdateUpdateAllowed || isLegacyBirthdateBug(data?.birthdate || '')
|
||||
|
||||
useEffect(() => {
|
||||
// just counting overall hits here
|
||||
logger.metric(`blockedGeoOverlay:shown`, {})
|
||||
}, [])
|
||||
|
||||
const onPressLogout = useCallback(() => {
|
||||
if (isWeb) {
|
||||
// We're switching accounts, which remounts the entire app.
|
||||
// On mobile, this gets us Home, but on the web we also need reset the URL.
|
||||
// We can't change the URL via a navigate() call because the navigator
|
||||
// itself is about to unmount, and it calls pushState() too late.
|
||||
// So we change the URL ourselves. The navigator will pick it up on remount.
|
||||
history.pushState(null, '', '/')
|
||||
}
|
||||
logoutCurrentAccount('AgeAssuranceNoAccessScreen')
|
||||
}, [logoutCurrentAccount])
|
||||
|
||||
const birthdateUpdateText = canUpdateBirthday ? (
|
||||
<Text style={[textStyles]}>
|
||||
<Trans>
|
||||
If you believe your birthdate is incorrect, you can update it by{' '}
|
||||
<SimpleInlineLinkText
|
||||
label={_(msg`Click here to update your birthdate`)}
|
||||
style={[textStyles]}
|
||||
{...createStaticClick(() => {
|
||||
birthdateControl.open()
|
||||
})}>
|
||||
clicking here
|
||||
</SimpleInlineLinkText>
|
||||
.
|
||||
</Trans>
|
||||
</Text>
|
||||
) : (
|
||||
<Text style={[textStyles]}>
|
||||
<Trans>
|
||||
If you believe your birthdate is incorrect, please{' '}
|
||||
<SimpleInlineLinkText
|
||||
to={createSupportLink({code: SupportCode.AA_BIRTHDATE})}
|
||||
label={_(msg`Click here to contact our support team`)}
|
||||
style={[textStyles]}>
|
||||
contact our support team
|
||||
</SimpleInlineLinkText>
|
||||
.
|
||||
</Trans>
|
||||
</Text>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<ScrollView
|
||||
contentContainerStyle={[
|
||||
a.px_2xl,
|
||||
{
|
||||
paddingTop: isWeb ? a.p_5xl.padding : insets.top + a.p_2xl.padding,
|
||||
paddingBottom: 100,
|
||||
},
|
||||
]}>
|
||||
<View
|
||||
style={[
|
||||
a.mx_auto,
|
||||
a.w_full,
|
||||
web({
|
||||
maxWidth: 380,
|
||||
paddingTop: gtPhone ? '8vh' : undefined,
|
||||
}),
|
||||
{
|
||||
gap: 32,
|
||||
},
|
||||
]}>
|
||||
<View style={[a.align_start]}>
|
||||
<AgeAssuranceBadge />
|
||||
</View>
|
||||
|
||||
{hasDeclaredAge ? (
|
||||
<>
|
||||
{isAARegion ? (
|
||||
<>
|
||||
<View style={[a.gap_lg]}>
|
||||
<Text style={[textStyles]}>
|
||||
<Trans>
|
||||
You are accessing Bluesky from a region that legally
|
||||
requires us to verify your age before allowing you to
|
||||
access the app.
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
{!isBlocked && birthdateUpdateText}
|
||||
</View>
|
||||
|
||||
<AccessSection />
|
||||
</>
|
||||
) : (
|
||||
<View style={[a.gap_lg]}>
|
||||
<Text style={[textStyles]}>
|
||||
<Trans>
|
||||
Unfortunately, the birthdate you have saved to your
|
||||
profile makes you too young to access Bluesky.
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
{birthdateUpdateText}
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<View style={[a.gap_lg]}>
|
||||
<Text style={[textStyles]}>
|
||||
<Trans>
|
||||
It looks like you haven't added your birthdate. You must
|
||||
provide an accurate date of birth to use Bluesky.
|
||||
</Trans>
|
||||
</Text>
|
||||
<Button
|
||||
color="primary"
|
||||
size="large"
|
||||
label={_(msg`Click here to update your birthdate`)}
|
||||
onPress={() => birthdateControl.open()}>
|
||||
<ButtonText>
|
||||
<Trans>Add your birthdate</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={[a.pt_lg, a.gap_xl]}>
|
||||
<Logo width={120} textFill={t.atoms.text.color} />
|
||||
<Text style={[a.text_sm, a.italic, t.atoms.text_contrast_medium]}>
|
||||
<Trans>
|
||||
To log out,{' '}
|
||||
<SimpleInlineLinkText
|
||||
label={_(msg`Click here to log out`)}
|
||||
{...createStaticClick(() => {
|
||||
onPressLogout()
|
||||
})}>
|
||||
click here
|
||||
</SimpleInlineLinkText>
|
||||
.
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
<BirthDateSettingsDialog control={birthdateControl} />
|
||||
|
||||
{/*
|
||||
* While this blocking overlay is up, other dialogs in the shell
|
||||
* are not mounted, so it _should_ be safe to use these here
|
||||
* without fear of other modals showing up.
|
||||
*/}
|
||||
<BottomSheetOutlet />
|
||||
<PortalOutlet />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function AccessSection() {
|
||||
const t = useTheme()
|
||||
const {_, i18n} = useLingui()
|
||||
const control = useDialogControl()
|
||||
const appealControl = Dialog.useDialogControl()
|
||||
const locationControl = Dialog.useDialogControl()
|
||||
const getTimeAgo = useGetTimeAgo()
|
||||
const {setDeviceGeolocation} = useDeviceGeolocationApi()
|
||||
const computeAgeAssuranceRegionAccess = useComputeAgeAssuranceRegionAccess()
|
||||
|
||||
const aa = useAgeAssurance()
|
||||
const {status, lastInitiatedAt} = aa.state
|
||||
const isBlocked = status === aa.Status.Blocked
|
||||
const hasInitiated = !!lastInitiatedAt
|
||||
const timeAgo = lastInitiatedAt
|
||||
? getTimeAgo(lastInitiatedAt, new Date())
|
||||
: null
|
||||
const diff = lastInitiatedAt
|
||||
? dateDiff(lastInitiatedAt, new Date(), 'down')
|
||||
: null
|
||||
|
||||
return (
|
||||
<>
|
||||
<AgeAssuranceInitDialog control={control} />
|
||||
<AgeAssuranceAppealDialog control={appealControl} />
|
||||
|
||||
<View style={[a.gap_xl]}>
|
||||
{isBlocked ? (
|
||||
<Admonition type="warning">
|
||||
<Trans>
|
||||
You are currently unable to access Bluesky's Age Assurance flow.
|
||||
Please{' '}
|
||||
<SimpleInlineLinkText
|
||||
label={_(msg`Contact our moderation team`)}
|
||||
{...createStaticClick(() => {
|
||||
appealControl.open()
|
||||
logger.metric('ageAssurance:appealDialogOpen', {})
|
||||
})}>
|
||||
contact our moderation team
|
||||
</SimpleInlineLinkText>{' '}
|
||||
if you believe this is an error.
|
||||
</Trans>
|
||||
</Admonition>
|
||||
) : (
|
||||
<>
|
||||
<View style={[a.gap_md]}>
|
||||
<Button
|
||||
label={_(msg`Verify now`)}
|
||||
size="large"
|
||||
color={hasInitiated ? 'secondary' : 'primary'}
|
||||
onPress={() => {
|
||||
control.open()
|
||||
logger.metric('ageAssurance:initDialogOpen', {
|
||||
hasInitiatedPreviously: hasInitiated,
|
||||
})
|
||||
}}>
|
||||
<ButtonIcon icon={ShieldIcon} />
|
||||
<ButtonText>
|
||||
{hasInitiated ? (
|
||||
<Trans>Verify again</Trans>
|
||||
) : (
|
||||
<Trans>Verify now</Trans>
|
||||
)}
|
||||
</ButtonText>
|
||||
</Button>
|
||||
|
||||
{lastInitiatedAt && timeAgo && diff ? (
|
||||
<Text
|
||||
style={[a.text_sm, a.italic, t.atoms.text_contrast_medium]}
|
||||
title={i18n.date(lastInitiatedAt, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'medium',
|
||||
})}>
|
||||
{diff.value === 0 ? (
|
||||
<Trans>Last initiated just now</Trans>
|
||||
) : (
|
||||
<Trans>Last initiated {timeAgo} ago</Trans>
|
||||
)}
|
||||
</Text>
|
||||
) : (
|
||||
<Text
|
||||
style={[a.text_sm, a.italic, t.atoms.text_contrast_medium]}>
|
||||
<Trans>Age assurance only takes a few minutes</Trans>
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
|
||||
<View style={[a.gap_xs]}>
|
||||
{isNative && (
|
||||
<>
|
||||
<Admonition>
|
||||
<Trans>
|
||||
Is your location not accurate?{' '}
|
||||
<SimpleInlineLinkText
|
||||
label={_(msg`Confirm your location`)}
|
||||
{...createStaticClick(() => {
|
||||
locationControl.open()
|
||||
})}>
|
||||
Tap here to confirm your location.
|
||||
</SimpleInlineLinkText>{' '}
|
||||
</Trans>
|
||||
</Admonition>
|
||||
|
||||
<DeviceLocationRequestDialog
|
||||
control={locationControl}
|
||||
onLocationAcquired={props => {
|
||||
const access = computeAgeAssuranceRegionAccess(
|
||||
props.geolocation,
|
||||
)
|
||||
if (access !== aa.Access.Full) {
|
||||
props.disableDialogAction()
|
||||
props.setDialogError(
|
||||
_(
|
||||
msg`We're sorry, but based on your device's location, you are currently located in a region that requires age assurance.`,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
props.closeDialog(() => {
|
||||
// set this after close!
|
||||
setDeviceGeolocation(props.geolocation)
|
||||
Toast.show(_(msg`Thanks! You're all set.`), {
|
||||
type: 'success',
|
||||
})
|
||||
})
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import {Dimensions, View} from 'react-native'
|
||||
import * as Linking from 'expo-linking'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {retry} from '#/lib/async/retry'
|
||||
import {wait} from '#/lib/async/wait'
|
||||
import {parseLinkingUrl} from '#/lib/parseLinkingUrl'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {isIOS} from '#/platform/detection'
|
||||
import {useAgent, useSession} from '#/state/session'
|
||||
import {atoms as a, platform, useBreakpoints, useTheme} from '#/alf'
|
||||
import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import {FullWindowOverlay} from '#/components/FullWindowOverlay'
|
||||
import {CheckThick_Stroke2_Corner0_Rounded as SuccessIcon} from '#/components/icons/Check'
|
||||
import {CircleInfo_Stroke2_Corner0_Rounded as ErrorIcon} from '#/components/icons/CircleInfo'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {refetchAgeAssuranceServerState} from '#/ageAssurance'
|
||||
import {logger} from '#/ageAssurance'
|
||||
|
||||
export type RedirectOverlayState = {
|
||||
result: 'success' | 'unknown'
|
||||
actorDid: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and parse the query parameters returned from the age assurance
|
||||
* redirect. If not valid, returns `undefined` and the dialog will not open.
|
||||
*/
|
||||
export function parseRedirectOverlayState(
|
||||
state: {
|
||||
result?: string
|
||||
actorDid?: string
|
||||
} = {},
|
||||
): RedirectOverlayState | undefined {
|
||||
let result: RedirectOverlayState['result'] = 'unknown'
|
||||
const actorDid = state.actorDid
|
||||
|
||||
switch (state.result) {
|
||||
case 'success':
|
||||
result = 'success'
|
||||
break
|
||||
case 'unknown':
|
||||
default:
|
||||
result = 'unknown'
|
||||
break
|
||||
}
|
||||
|
||||
if (actorDid) {
|
||||
return {
|
||||
result,
|
||||
actorDid,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const Context = createContext<{
|
||||
isOpen: boolean
|
||||
open: (state: RedirectOverlayState) => void
|
||||
close: () => void
|
||||
}>({
|
||||
isOpen: false,
|
||||
open: () => {},
|
||||
close: () => {},
|
||||
})
|
||||
|
||||
export function useRedirectOverlayContext() {
|
||||
return useContext(Context)
|
||||
}
|
||||
|
||||
export function Provider({children}: {children?: React.ReactNode}) {
|
||||
const {currentAccount} = useSession()
|
||||
const incomingUrl = Linking.useLinkingURL()
|
||||
const [state, setState] = useState<RedirectOverlayState | null>(() => {
|
||||
if (!incomingUrl) return null
|
||||
const url = parseLinkingUrl(incomingUrl)
|
||||
if (url.pathname !== '/intent/age-assurance') return null
|
||||
const params = url.searchParams
|
||||
const state = parseRedirectOverlayState({
|
||||
result: params.get('result') ?? undefined,
|
||||
actorDid: params.get('actorDid') ?? undefined,
|
||||
})
|
||||
|
||||
if (isWeb) {
|
||||
// Clear the URL parameters so they don't re-trigger
|
||||
history.pushState(null, '', '/')
|
||||
}
|
||||
|
||||
/*
|
||||
* If we don't have an account or the account doesn't match, do
|
||||
* nothing. By the time the user switches to their other account, AA
|
||||
* state should be ready for them.
|
||||
*/
|
||||
if (state && currentAccount && state.actorDid === currentAccount.did) {
|
||||
return state
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
const open = useCallback((state: RedirectOverlayState) => {
|
||||
setState(state)
|
||||
}, [])
|
||||
const close = useCallback(() => {
|
||||
setState(null)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Context.Provider
|
||||
value={useMemo(
|
||||
() => ({
|
||||
isOpen: state !== null,
|
||||
open,
|
||||
close,
|
||||
}),
|
||||
[state, open, close],
|
||||
)}>
|
||||
{children}
|
||||
</Context.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function RedirectOverlay() {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {isOpen} = useRedirectOverlayContext()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
|
||||
return isOpen ? (
|
||||
<FullWindowOverlay>
|
||||
<View
|
||||
style={[
|
||||
a.fixed,
|
||||
a.inset_0,
|
||||
// setting a zIndex when using FullWindowOverlay on iOS
|
||||
// means the taps pass straight through to the underlying content (???)
|
||||
// so don't set it on iOS. FullWindowOverlay already does the job.
|
||||
!isIOS && {zIndex: 9999},
|
||||
t.atoms.bg,
|
||||
gtMobile ? a.p_2xl : a.p_xl,
|
||||
a.align_center,
|
||||
// @ts-ignore
|
||||
platform({
|
||||
web: {
|
||||
paddingTop: '35vh',
|
||||
},
|
||||
default: {
|
||||
paddingTop: Dimensions.get('window').height * 0.35,
|
||||
},
|
||||
}),
|
||||
]}>
|
||||
<View
|
||||
role="dialog"
|
||||
aria-role="dialog"
|
||||
aria-label={_(msg`Verifying your age assurance status`)}>
|
||||
<View style={[a.pb_3xl, {width: 300}]}>
|
||||
<Inner />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</FullWindowOverlay>
|
||||
) : null
|
||||
}
|
||||
|
||||
function Inner() {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const agent = useAgent()
|
||||
const polling = useRef(false)
|
||||
const unmounted = useRef(false)
|
||||
const [error, setError] = useState(false)
|
||||
const [success, setSuccess] = useState(false)
|
||||
const {close} = useRedirectOverlayContext()
|
||||
|
||||
useEffect(() => {
|
||||
if (polling.current) return
|
||||
|
||||
polling.current = true
|
||||
|
||||
logger.metric('ageAssurance:redirectDialogOpen', {})
|
||||
|
||||
wait(
|
||||
3e3,
|
||||
retry(
|
||||
5,
|
||||
() => true,
|
||||
async () => {
|
||||
if (!agent.session) return
|
||||
if (unmounted.current) return
|
||||
|
||||
const data = await refetchAgeAssuranceServerState({agent})
|
||||
|
||||
if (data?.state.status !== 'assured') {
|
||||
throw new Error(
|
||||
`Polling for age assurance state did not receive assured status`,
|
||||
)
|
||||
}
|
||||
|
||||
return data
|
||||
},
|
||||
1e3,
|
||||
),
|
||||
)
|
||||
.then(async data => {
|
||||
if (!data) return
|
||||
if (!agent.session) return
|
||||
if (unmounted.current) return
|
||||
|
||||
setSuccess(true)
|
||||
|
||||
logger.metric('ageAssurance:redirectDialogSuccess', {})
|
||||
})
|
||||
.catch(() => {
|
||||
if (unmounted.current) return
|
||||
setError(true)
|
||||
logger.metric('ageAssurance:redirectDialogFail', {})
|
||||
})
|
||||
|
||||
return () => {
|
||||
unmounted.current = true
|
||||
}
|
||||
}, [agent])
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<>
|
||||
<View style={[a.align_start, a.w_full]}>
|
||||
<AgeAssuranceBadge />
|
||||
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.justify_between,
|
||||
a.align_center,
|
||||
a.gap_sm,
|
||||
a.pt_lg,
|
||||
a.pb_md,
|
||||
]}>
|
||||
<SuccessIcon size="sm" fill={t.palette.positive_500} />
|
||||
<Text style={[a.text_3xl, a.font_bold]}>
|
||||
<Trans>Success</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Text style={[a.text_md, a.leading_snug]}>
|
||||
<Trans>
|
||||
We've confirmed your age assurance status. You can now close this
|
||||
dialog.
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
<View style={[a.w_full, a.pt_lg]}>
|
||||
<Button
|
||||
label={_(msg`Close`)}
|
||||
size="large"
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
onPress={() => close()}>
|
||||
<ButtonText>
|
||||
<Trans>Close</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<View style={[a.align_start, a.w_full]}>
|
||||
<AgeAssuranceBadge />
|
||||
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.justify_between,
|
||||
a.align_center,
|
||||
a.gap_sm,
|
||||
a.pt_lg,
|
||||
a.pb_md,
|
||||
]}>
|
||||
{error && <ErrorIcon size="lg" fill={t.palette.negative_500} />}
|
||||
|
||||
<Text style={[a.text_3xl, a.font_bold]}>
|
||||
{error ? <Trans>Connection issue</Trans> : <Trans>Verifying</Trans>}
|
||||
</Text>
|
||||
|
||||
{!error && <Loader size="lg" />}
|
||||
</View>
|
||||
|
||||
<Text style={[a.text_md, t.atoms.text_contrast_medium, a.leading_snug]}>
|
||||
{error ? (
|
||||
<Trans>
|
||||
We were unable to receive the verification due to a connection
|
||||
issue. It may arrive later. If it does, your account will update
|
||||
automatically.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
We're confirming your age assurance status with our servers. This
|
||||
should only take a few seconds.
|
||||
</Trans>
|
||||
)}
|
||||
</Text>
|
||||
|
||||
{error && (
|
||||
<View style={[a.w_full, a.pt_lg]}>
|
||||
<Button
|
||||
label={_(msg`Close`)}
|
||||
size="large"
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
onPress={() => close()}>
|
||||
<ButtonText>
|
||||
<Trans>Close</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,495 @@
|
||||
import {createContext, useCallback, useContext, useEffect, useMemo} from 'react'
|
||||
import {
|
||||
type AppBskyAgeassuranceDefs,
|
||||
type AppBskyAgeassuranceGetConfig,
|
||||
type AppBskyAgeassuranceGetState,
|
||||
AtpAgent,
|
||||
getAgeAssuranceRegionConfig,
|
||||
} from '@atproto/api'
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import {createAsyncStoragePersister} from '@tanstack/query-async-storage-persister'
|
||||
import {focusManager, QueryClient, useQuery} from '@tanstack/react-query'
|
||||
import {persistQueryClient} from '@tanstack/react-query-persist-client'
|
||||
import debounce from 'lodash.debounce'
|
||||
|
||||
import {networkRetry} from '#/lib/async/retry'
|
||||
import {PUBLIC_BSKY_SERVICE} from '#/lib/constants'
|
||||
import {getAge} from '#/lib/strings/time'
|
||||
import {snoozeBirthdateUpdateAllowedForDid} from '#/state/birthdate'
|
||||
import {useAgent, useSession} from '#/state/session'
|
||||
import * as debug from '#/ageAssurance/debug'
|
||||
import {logger} from '#/ageAssurance/logger'
|
||||
import {isLegacyBirthdateBug} from '#/ageAssurance/util'
|
||||
import {IS_DEV} from '#/env'
|
||||
import {device} from '#/storage'
|
||||
|
||||
/**
|
||||
* Special query client for age assurance data so we can prefetch on app
|
||||
* load without interfering with other queries.
|
||||
*/
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
/**
|
||||
* We clear this manually, so disable automatic garbage collection.
|
||||
* @see https://tanstack.com/query/latest/docs/framework/react/plugins/persistQueryClient#how-it-works
|
||||
*/
|
||||
gcTime: Infinity,
|
||||
},
|
||||
},
|
||||
})
|
||||
const persister = createAsyncStoragePersister({
|
||||
storage: AsyncStorage,
|
||||
key: 'age-assurance-query-client',
|
||||
})
|
||||
const [, cacheHydrationPromise] = persistQueryClient({
|
||||
queryClient: qc,
|
||||
persister,
|
||||
})
|
||||
|
||||
function getDidFromAgentSession(agent: AtpAgent) {
|
||||
const sessionManager = agent.sessionManager
|
||||
if (!sessionManager || !sessionManager.did) return
|
||||
return sessionManager.did
|
||||
}
|
||||
|
||||
/*
|
||||
* Optimistic data
|
||||
*/
|
||||
|
||||
const createdAtCache = new Map<string, string>()
|
||||
export function setCreatedAtForDid({
|
||||
did,
|
||||
createdAt,
|
||||
}: {
|
||||
did: string
|
||||
createdAt: string
|
||||
}) {
|
||||
createdAtCache.set(did, createdAt)
|
||||
}
|
||||
const birthdateCache = new Map<string, string>()
|
||||
export function setBirthdateForDid({
|
||||
did,
|
||||
birthdate,
|
||||
}: {
|
||||
did: string
|
||||
birthdate: string
|
||||
}) {
|
||||
birthdateCache.set(did, birthdate)
|
||||
}
|
||||
|
||||
/*
|
||||
* Config
|
||||
*/
|
||||
|
||||
export const configQueryKey = ['config']
|
||||
export async function getConfig() {
|
||||
if (debug.enabled) return debug.resolve(debug.config)
|
||||
const agent = new AtpAgent({
|
||||
service: PUBLIC_BSKY_SERVICE,
|
||||
})
|
||||
const res = await agent.app.bsky.ageassurance.getConfig()
|
||||
return res.data
|
||||
}
|
||||
export function getConfigFromCache():
|
||||
| AppBskyAgeassuranceGetConfig.OutputSchema
|
||||
| undefined {
|
||||
return qc.getQueryData<AppBskyAgeassuranceGetConfig.OutputSchema>(
|
||||
configQueryKey,
|
||||
)
|
||||
}
|
||||
let configPrefetchPromise: Promise<void> | undefined
|
||||
export async function prefetchConfig() {
|
||||
if (configPrefetchPromise) {
|
||||
logger.debug(`prefetchAgeAssuranceConfig: already in progress`)
|
||||
return
|
||||
}
|
||||
|
||||
configPrefetchPromise = new Promise(async resolve => {
|
||||
await cacheHydrationPromise
|
||||
const cached = getConfigFromCache()
|
||||
|
||||
if (cached) {
|
||||
logger.debug(`prefetchAgeAssuranceConfig: using cache`)
|
||||
resolve()
|
||||
} else {
|
||||
try {
|
||||
logger.debug(`prefetchAgeAssuranceConfig: resolving...`)
|
||||
const res = await networkRetry(3, () => getConfig())
|
||||
qc.setQueryData<AppBskyAgeassuranceGetConfig.OutputSchema>(
|
||||
configQueryKey,
|
||||
res,
|
||||
)
|
||||
} catch (e: any) {
|
||||
logger.warn(`prefetchAgeAssuranceConfig: failed`, {
|
||||
safeMessage: e.message,
|
||||
})
|
||||
} finally {
|
||||
resolve()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
export function useConfigQuery() {
|
||||
return useQuery(
|
||||
{
|
||||
/**
|
||||
* Will re-fetch when stale, at most every hour (or 5s in dev for easier
|
||||
* testing).
|
||||
*
|
||||
* @see https://tanstack.com/query/latest/docs/framework/react/guides/initial-query-data#initial-data-from-the-cache-with-initialdataupdatedat
|
||||
*/
|
||||
staleTime: IS_DEV ? 5e3 : 1000 * 60 * 60,
|
||||
initialData: getConfigFromCache(),
|
||||
initialDataUpdatedAt: () =>
|
||||
qc.getQueryState(configQueryKey)?.dataUpdatedAt,
|
||||
queryKey: configQueryKey,
|
||||
async queryFn() {
|
||||
logger.debug(`useConfigQuery: fetching config`)
|
||||
return getConfig()
|
||||
},
|
||||
},
|
||||
qc,
|
||||
)
|
||||
}
|
||||
|
||||
/*
|
||||
* Server state
|
||||
*/
|
||||
|
||||
export function createServerStateQueryKey({did}: {did: string}) {
|
||||
return ['serverState', did]
|
||||
}
|
||||
export async function getServerState({agent}: {agent: AtpAgent}) {
|
||||
if (debug.enabled && debug.serverState)
|
||||
return debug.resolve(debug.serverState)
|
||||
const geolocation = device.get(['mergedGeolocation'])
|
||||
if (!geolocation || !geolocation.countryCode) {
|
||||
logger.error(`getServerState: missing geolocation countryCode`)
|
||||
return
|
||||
}
|
||||
const {data} = await agent.app.bsky.ageassurance.getState({
|
||||
countryCode: geolocation.countryCode,
|
||||
regionCode: geolocation.regionCode,
|
||||
})
|
||||
const did = getDidFromAgentSession(agent)
|
||||
if (data && did && createdAtCache.has(did)) {
|
||||
/*
|
||||
* If account was just created, just use the local cache if available. On
|
||||
* subsequent reloads, the server should have the correct value.
|
||||
*/
|
||||
data.metadata.accountCreatedAt = createdAtCache.get(did)
|
||||
}
|
||||
return data ?? null
|
||||
}
|
||||
export function getServerStateFromCache({
|
||||
did,
|
||||
}: {
|
||||
did: string
|
||||
}): AppBskyAgeassuranceGetState.OutputSchema | undefined {
|
||||
return qc.getQueryData<AppBskyAgeassuranceGetState.OutputSchema>(
|
||||
createServerStateQueryKey({did}),
|
||||
)
|
||||
}
|
||||
export async function prefetchServerState({agent}: {agent: AtpAgent}) {
|
||||
const did = getDidFromAgentSession(agent)
|
||||
|
||||
if (!did) return
|
||||
|
||||
await cacheHydrationPromise
|
||||
const qk = createServerStateQueryKey({did})
|
||||
const cached = getServerStateFromCache({did})
|
||||
|
||||
if (cached) {
|
||||
logger.debug(`prefetchServerState: using cache`)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
logger.debug(`prefetchServerState: resolving...`)
|
||||
const res = await networkRetry(3, () => getServerState({agent}))
|
||||
qc.setQueryData<AppBskyAgeassuranceGetState.OutputSchema>(qk, res)
|
||||
} catch (e: any) {
|
||||
logger.warn(`prefetchServerState: failed`, {
|
||||
safeMessage: e.message,
|
||||
})
|
||||
}
|
||||
}
|
||||
export async function refetchServerState({agent}: {agent: AtpAgent}) {
|
||||
const did = getDidFromAgentSession(agent)
|
||||
if (!did) return
|
||||
logger.debug(`refetchServerState: fetching...`)
|
||||
const res = await networkRetry(3, () => getServerState({agent}))
|
||||
qc.setQueryData<AppBskyAgeassuranceGetState.OutputSchema>(
|
||||
createServerStateQueryKey({did}),
|
||||
res,
|
||||
)
|
||||
return res
|
||||
}
|
||||
export function usePatchServerState() {
|
||||
const {currentAccount} = useSession()
|
||||
return useCallback(
|
||||
async (next: AppBskyAgeassuranceDefs.State) => {
|
||||
if (!currentAccount) return
|
||||
const did = currentAccount.did
|
||||
const prev = getServerStateFromCache({did})
|
||||
const merged: AppBskyAgeassuranceGetState.OutputSchema = {
|
||||
metadata: {},
|
||||
...(prev || {}),
|
||||
state: next,
|
||||
}
|
||||
qc.setQueryData<AppBskyAgeassuranceGetState.OutputSchema>(
|
||||
createServerStateQueryKey({did}),
|
||||
merged,
|
||||
)
|
||||
},
|
||||
[currentAccount],
|
||||
)
|
||||
}
|
||||
export function useServerStateQuery() {
|
||||
const agent = useAgent()
|
||||
const did = getDidFromAgentSession(agent)
|
||||
const query = useQuery(
|
||||
{
|
||||
enabled: !!did,
|
||||
initialData: () => {
|
||||
if (!did) return
|
||||
return getServerStateFromCache({did})
|
||||
},
|
||||
queryKey: createServerStateQueryKey({did: did!}),
|
||||
async queryFn() {
|
||||
return getServerState({agent})
|
||||
},
|
||||
},
|
||||
qc,
|
||||
)
|
||||
const refetch = useMemo(() => debounce(query.refetch, 100), [query.refetch])
|
||||
|
||||
const isAssured = query.data?.state?.status === 'assured'
|
||||
|
||||
/**
|
||||
* `refetchOnWindowFocus` doesn't seem to want to work for this custom query
|
||||
* client, so we manually subscribe to focus changes.
|
||||
*/
|
||||
useEffect(() => {
|
||||
return focusManager.subscribe(() => {
|
||||
// logged out
|
||||
if (!did) return
|
||||
|
||||
const isFocused = focusManager.isFocused()
|
||||
|
||||
if (!isFocused) return
|
||||
|
||||
const config = getConfigFromCache()
|
||||
const geolocation = device.get(['mergedGeolocation'])
|
||||
const isAArequired = Boolean(
|
||||
config &&
|
||||
geolocation &&
|
||||
!!getAgeAssuranceRegionConfig(config, {
|
||||
countryCode: geolocation?.countryCode ?? '',
|
||||
regionCode: geolocation?.regionCode,
|
||||
}),
|
||||
)
|
||||
|
||||
// only refetch when needed
|
||||
if (isAssured || !isAArequired) return
|
||||
|
||||
refetch()
|
||||
})
|
||||
}, [did, refetch, isAssured])
|
||||
|
||||
return query
|
||||
}
|
||||
|
||||
/*
|
||||
* Other required data
|
||||
*/
|
||||
|
||||
export type OtherRequiredData = {
|
||||
birthdate: string | undefined
|
||||
}
|
||||
export function createOtherRequiredDataQueryKey({did}: {did: string}) {
|
||||
return ['otherRequiredData', did]
|
||||
}
|
||||
export async function getOtherRequiredData({
|
||||
agent,
|
||||
}: {
|
||||
agent: AtpAgent
|
||||
}): Promise<OtherRequiredData> {
|
||||
if (debug.enabled) return debug.resolve(debug.otherRequiredData)
|
||||
const [prefs] = await Promise.all([agent.getPreferences()])
|
||||
const data: OtherRequiredData = {
|
||||
birthdate: prefs.birthDate ? prefs.birthDate.toISOString() : undefined,
|
||||
}
|
||||
const did = getDidFromAgentSession(agent)
|
||||
if (data && did && birthdateCache.has(did)) {
|
||||
/*
|
||||
* If birthdate was just set, use the local cache value. On subsequent
|
||||
* reloads, the server should have the correct value.
|
||||
*/
|
||||
data.birthdate = birthdateCache.get(did)
|
||||
}
|
||||
|
||||
/**
|
||||
* If the user is under the minimum age, and the birthdate is not due to
|
||||
* the legacy bug, snooze further birthdate updates for this user.
|
||||
*/
|
||||
if (data.birthdate && !isLegacyBirthdateBug(data.birthdate)) {
|
||||
snoozeBirthdateUpdateAllowedForDid(did!)
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
export function getOtherRequiredDataFromCache({
|
||||
did,
|
||||
}: {
|
||||
did: string
|
||||
}): OtherRequiredData | undefined {
|
||||
return qc.getQueryData<OtherRequiredData>(
|
||||
createOtherRequiredDataQueryKey({did}),
|
||||
)
|
||||
}
|
||||
export async function prefetchOtherRequiredData({agent}: {agent: AtpAgent}) {
|
||||
const did = getDidFromAgentSession(agent)
|
||||
|
||||
if (!did) return
|
||||
|
||||
await cacheHydrationPromise
|
||||
const qk = createOtherRequiredDataQueryKey({did})
|
||||
const cached = getOtherRequiredDataFromCache({did})
|
||||
|
||||
if (cached) {
|
||||
logger.debug(`prefetchOtherRequiredData: using cache`)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
logger.debug(`prefetchOtherRequiredData: resolving...`)
|
||||
const res = await networkRetry(3, () => getOtherRequiredData({agent}))
|
||||
qc.setQueryData<OtherRequiredData>(qk, res)
|
||||
} catch (e: any) {
|
||||
logger.warn(`prefetchOtherRequiredData: failed`, {
|
||||
safeMessage: e.message,
|
||||
})
|
||||
}
|
||||
}
|
||||
export function usePatchOtherRequiredData() {
|
||||
const {currentAccount} = useSession()
|
||||
return useCallback(
|
||||
async (next: OtherRequiredData) => {
|
||||
if (!currentAccount) return
|
||||
const did = currentAccount.did
|
||||
const prev = getOtherRequiredDataFromCache({did})
|
||||
const merged: OtherRequiredData = {
|
||||
...(prev || {}),
|
||||
...next,
|
||||
}
|
||||
qc.setQueryData<OtherRequiredData>(
|
||||
createOtherRequiredDataQueryKey({did}),
|
||||
merged,
|
||||
)
|
||||
},
|
||||
[currentAccount],
|
||||
)
|
||||
}
|
||||
export function useOtherRequiredDataQuery() {
|
||||
const agent = useAgent()
|
||||
const did = getDidFromAgentSession(agent)
|
||||
return useQuery(
|
||||
{
|
||||
enabled: !!did,
|
||||
initialData: () => {
|
||||
if (!did) return
|
||||
return getOtherRequiredDataFromCache({did})
|
||||
},
|
||||
queryKey: createOtherRequiredDataQueryKey({did: did!}),
|
||||
async queryFn() {
|
||||
return getOtherRequiredData({agent})
|
||||
},
|
||||
},
|
||||
qc,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to prefetch all age assurance data.
|
||||
*/
|
||||
export function prefetchAgeAssuranceData({agent}: {agent: AtpAgent}) {
|
||||
return Promise.allSettled([
|
||||
// config fetch initiated at the top of the App.platform.tsx files, awaited here
|
||||
configPrefetchPromise,
|
||||
prefetchServerState({agent}),
|
||||
prefetchOtherRequiredData({agent}),
|
||||
])
|
||||
}
|
||||
|
||||
export function clearAgeAssuranceDataForDid({did}: {did: string}) {
|
||||
logger.debug(`clearAgeAssuranceDataForDid: ${did}`)
|
||||
qc.removeQueries({queryKey: createServerStateQueryKey({did}), exact: true})
|
||||
qc.removeQueries({
|
||||
queryKey: createOtherRequiredDataQueryKey({did}),
|
||||
exact: true,
|
||||
})
|
||||
}
|
||||
|
||||
export function clearAgeAssuranceData() {
|
||||
logger.debug(`clearAgeAssuranceData`)
|
||||
qc.clear()
|
||||
}
|
||||
|
||||
/*
|
||||
* Context
|
||||
*/
|
||||
|
||||
export type AgeAssuranceData = {
|
||||
config: AppBskyAgeassuranceDefs.Config | undefined
|
||||
state: AppBskyAgeassuranceDefs.State | undefined
|
||||
data:
|
||||
| {
|
||||
accountCreatedAt: AppBskyAgeassuranceDefs.StateMetadata['accountCreatedAt']
|
||||
declaredAge: number | undefined
|
||||
birthdate: string | undefined
|
||||
}
|
||||
| undefined
|
||||
}
|
||||
export const AgeAssuranceDataContext = createContext<AgeAssuranceData>({
|
||||
config: undefined,
|
||||
state: undefined,
|
||||
data: {
|
||||
accountCreatedAt: undefined,
|
||||
declaredAge: undefined,
|
||||
birthdate: undefined,
|
||||
},
|
||||
})
|
||||
export function useAgeAssuranceDataContext() {
|
||||
return useContext(AgeAssuranceDataContext)
|
||||
}
|
||||
export function AgeAssuranceDataProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const {data: config} = useConfigQuery()
|
||||
const serverState = useServerStateQuery()
|
||||
const {state, metadata} = serverState.data || {}
|
||||
const {data} = useOtherRequiredDataQuery()
|
||||
const ctx = useMemo(
|
||||
() => ({
|
||||
config,
|
||||
state,
|
||||
data: {
|
||||
accountCreatedAt: metadata?.accountCreatedAt,
|
||||
declaredAge: data?.birthdate
|
||||
? getAge(new Date(data.birthdate))
|
||||
: undefined,
|
||||
birthdate: data?.birthdate,
|
||||
},
|
||||
}),
|
||||
[config, state, data, metadata],
|
||||
)
|
||||
return (
|
||||
<AgeAssuranceDataContext.Provider value={ctx}>
|
||||
{children}
|
||||
</AgeAssuranceDataContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import {
|
||||
ageAssuranceRuleIDs as ids,
|
||||
type AppBskyAgeassuranceDefs,
|
||||
type AppBskyAgeassuranceGetState,
|
||||
} from '@atproto/api'
|
||||
|
||||
import {type OtherRequiredData} from '#/ageAssurance/data'
|
||||
import {IS_DEV} from '#/env'
|
||||
import {type Geolocation} from '#/geolocation'
|
||||
|
||||
export const enabled = IS_DEV && false
|
||||
|
||||
export const geolocation: Geolocation | undefined = enabled
|
||||
? {
|
||||
countryCode: 'AA',
|
||||
regionCode: undefined,
|
||||
}
|
||||
: undefined
|
||||
|
||||
export const deviceGeolocation: Geolocation | undefined = enabled
|
||||
? {
|
||||
countryCode: 'AA',
|
||||
regionCode: undefined,
|
||||
}
|
||||
: undefined
|
||||
|
||||
export const config: AppBskyAgeassuranceDefs.Config = {
|
||||
regions: [
|
||||
{
|
||||
countryCode: 'AA',
|
||||
regionCode: undefined,
|
||||
rules: [
|
||||
{
|
||||
$type: ids.IfAccountNewerThan,
|
||||
date: '2025-12-01T00:00:00Z',
|
||||
access: 'none',
|
||||
},
|
||||
{
|
||||
$type: ids.IfAssuredOverAge,
|
||||
age: 18,
|
||||
access: 'full',
|
||||
},
|
||||
{
|
||||
$type: ids.IfAssuredOverAge,
|
||||
age: 16,
|
||||
access: 'safe',
|
||||
},
|
||||
{
|
||||
$type: ids.IfDeclaredUnderAge,
|
||||
age: 16,
|
||||
access: 'none',
|
||||
},
|
||||
{
|
||||
$type: ids.Default,
|
||||
access: 'safe',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export const otherRequiredData: OtherRequiredData = {
|
||||
birthdate: new Date(2000, 1, 1).toISOString(),
|
||||
}
|
||||
|
||||
const serverStateEnabled = false
|
||||
export const serverState: AppBskyAgeassuranceGetState.OutputSchema | undefined =
|
||||
serverStateEnabled
|
||||
? {
|
||||
state: {
|
||||
lastInitiatedAt: new Date(2023, 5, 1).toISOString(),
|
||||
status: 'assured',
|
||||
access: 'safe',
|
||||
},
|
||||
metadata: {
|
||||
accountCreatedAt: new Date(2023, 11, 1).toISOString(),
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
|
||||
export async function resolve<T>(data: T) {
|
||||
await new Promise(y => setTimeout(y, 2000)) // simulate network
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import {createContext, useCallback, useContext, useEffect, useMemo} from 'react'
|
||||
|
||||
import {useGetAndRegisterPushToken} from '#/lib/notifications/notifications'
|
||||
import {Provider as RedirectOverlayProvider} from '#/ageAssurance/components/RedirectOverlay'
|
||||
import {AgeAssuranceDataProvider} from '#/ageAssurance/data'
|
||||
import {useAgeAssuranceDataContext} from '#/ageAssurance/data'
|
||||
import {logger} from '#/ageAssurance/logger'
|
||||
import {
|
||||
useAgeAssuranceState,
|
||||
useOnAgeAssuranceAccessUpdate,
|
||||
} from '#/ageAssurance/state'
|
||||
import {
|
||||
AgeAssuranceAccess,
|
||||
type AgeAssuranceState,
|
||||
AgeAssuranceStatus,
|
||||
} from '#/ageAssurance/types'
|
||||
import {isUserUnderAdultAge} from '#/ageAssurance/util'
|
||||
|
||||
export {
|
||||
prefetchConfig as prefetchAgeAssuranceConfig,
|
||||
prefetchAgeAssuranceData,
|
||||
refetchServerState as refetchAgeAssuranceServerState,
|
||||
usePatchOtherRequiredData as usePatchAgeAssuranceOtherRequiredData,
|
||||
usePatchServerState as usePatchAgeAssuranceServerState,
|
||||
} from '#/ageAssurance/data'
|
||||
export {logger} from '#/ageAssurance/logger'
|
||||
|
||||
const AgeAssuranceStateContext = createContext<{
|
||||
Access: typeof AgeAssuranceAccess
|
||||
Status: typeof AgeAssuranceStatus
|
||||
state: AgeAssuranceState
|
||||
flags: {
|
||||
adultContentDisabled: boolean
|
||||
chatDisabled: boolean
|
||||
}
|
||||
}>({
|
||||
Access: AgeAssuranceAccess,
|
||||
Status: AgeAssuranceStatus,
|
||||
state: {
|
||||
lastInitiatedAt: undefined,
|
||||
status: AgeAssuranceStatus.Unknown,
|
||||
access: AgeAssuranceAccess.Full,
|
||||
},
|
||||
flags: {
|
||||
adultContentDisabled: false,
|
||||
chatDisabled: false,
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* THE MAIN AGE ASSURANCE CONTEXT HOOK
|
||||
*
|
||||
* Prefer this to using any of the lower-level data-provider hooks.
|
||||
*/
|
||||
export function useAgeAssurance() {
|
||||
return useContext(AgeAssuranceStateContext)
|
||||
}
|
||||
|
||||
export function Provider({children}: {children: React.ReactNode}) {
|
||||
return (
|
||||
<AgeAssuranceDataProvider>
|
||||
<InnerProvider>
|
||||
<RedirectOverlayProvider>{children}</RedirectOverlayProvider>
|
||||
</InnerProvider>
|
||||
</AgeAssuranceDataProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function InnerProvider({children}: {children: React.ReactNode}) {
|
||||
const state = useAgeAssuranceState()
|
||||
const {data} = useAgeAssuranceDataContext()
|
||||
const getAndRegisterPushToken = useGetAndRegisterPushToken()
|
||||
|
||||
const handleAccessUpdate = useCallback(
|
||||
(s: AgeAssuranceState) => {
|
||||
getAndRegisterPushToken({
|
||||
isAgeRestricted: s.access !== AgeAssuranceAccess.Full,
|
||||
})
|
||||
},
|
||||
[getAndRegisterPushToken],
|
||||
)
|
||||
useOnAgeAssuranceAccessUpdate(handleAccessUpdate)
|
||||
|
||||
useEffect(() => {
|
||||
logger.debug(`useAgeAssuranceState`, {state})
|
||||
}, [state])
|
||||
|
||||
return (
|
||||
<AgeAssuranceStateContext.Provider
|
||||
value={useMemo(() => {
|
||||
const chatDisabled = state.access !== AgeAssuranceAccess.Full
|
||||
const isUnderage = data?.birthdate
|
||||
? isUserUnderAdultAge(data.birthdate)
|
||||
: true
|
||||
const adultContentDisabled =
|
||||
state.access !== AgeAssuranceAccess.Full || isUnderage
|
||||
return {
|
||||
Access: AgeAssuranceAccess,
|
||||
Status: AgeAssuranceStatus,
|
||||
state,
|
||||
flags: {
|
||||
adultContentDisabled,
|
||||
chatDisabled,
|
||||
},
|
||||
}
|
||||
}, [state, data])}>
|
||||
{children}
|
||||
</AgeAssuranceStateContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import {useEffect, useMemo, useState} from 'react'
|
||||
import {computeAgeAssuranceRegionAccess} from '@atproto/api'
|
||||
|
||||
import {useSession} from '#/state/session'
|
||||
import {useAgeAssuranceDataContext} from '#/ageAssurance/data'
|
||||
import {logger} from '#/ageAssurance/logger'
|
||||
import {
|
||||
AgeAssuranceAccess,
|
||||
type AgeAssuranceState,
|
||||
AgeAssuranceStatus,
|
||||
parseAccessFromString,
|
||||
parseStatusFromString,
|
||||
} from '#/ageAssurance/types'
|
||||
import {getAgeAssuranceRegionConfigWithFallback} from '#/ageAssurance/util'
|
||||
import {useGeolocation} from '#/geolocation'
|
||||
|
||||
export function useAgeAssuranceState(): AgeAssuranceState {
|
||||
const {hasSession} = useSession()
|
||||
const geolocation = useGeolocation()
|
||||
const {config, state, data} = useAgeAssuranceDataContext()
|
||||
|
||||
return useMemo(() => {
|
||||
/**
|
||||
* This is where we control logged-out moderation prefs. It's all
|
||||
* downstream of AA now.
|
||||
*/
|
||||
if (!hasSession)
|
||||
return {
|
||||
status: AgeAssuranceStatus.Unknown,
|
||||
access: AgeAssuranceAccess.Safe,
|
||||
}
|
||||
|
||||
// should never happen, but need to guard
|
||||
if (!config) {
|
||||
logger.warn('useAgeAssuranceState: missing config')
|
||||
return {
|
||||
status: AgeAssuranceStatus.Unknown,
|
||||
access: AgeAssuranceAccess.Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
const region = getAgeAssuranceRegionConfigWithFallback(config, geolocation)
|
||||
const isAARequired = region.countryCode !== '*'
|
||||
const isTerminalState =
|
||||
state?.status === 'assured' || state?.status === 'blocked'
|
||||
|
||||
/*
|
||||
* If we are in a terminal state and AA is required for this region,
|
||||
* we can trust the server state completely and avoid recomputing.
|
||||
*/
|
||||
if (isTerminalState && isAARequired) {
|
||||
return {
|
||||
lastInitiatedAt: state.lastInitiatedAt,
|
||||
status: parseStatusFromString(state.status),
|
||||
access: parseAccessFromString(state.access),
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Otherwise, we need to compute the access based on the latest data. For
|
||||
* accounts with an accurate birthdate, our default fallback rules should
|
||||
* ensure correct access.
|
||||
*/
|
||||
const result = computeAgeAssuranceRegionAccess(region, data)
|
||||
const computed = {
|
||||
lastInitiatedAt: state?.lastInitiatedAt,
|
||||
// prefer server state
|
||||
status: state?.status
|
||||
? parseStatusFromString(state?.status)
|
||||
: AgeAssuranceStatus.Unknown,
|
||||
// prefer server state
|
||||
access: result
|
||||
? parseAccessFromString(result.access)
|
||||
: AgeAssuranceAccess.Full,
|
||||
}
|
||||
logger.debug('debug useAgeAssuranceState', {
|
||||
region,
|
||||
state,
|
||||
data,
|
||||
computed,
|
||||
})
|
||||
return computed
|
||||
}, [hasSession, geolocation, config, state, data])
|
||||
}
|
||||
|
||||
export function useOnAgeAssuranceAccessUpdate(
|
||||
cb: (state: AgeAssuranceState) => void,
|
||||
) {
|
||||
const state = useAgeAssuranceState()
|
||||
// start with null to ensure callback is called on first render
|
||||
const [prevAccess, setPrevAccess] = useState<AgeAssuranceAccess | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (prevAccess !== state.access) {
|
||||
setPrevAccess(state.access)
|
||||
cb(state)
|
||||
logger.debug(`useOnAgeAssuranceAccessUpdate`, {state})
|
||||
}
|
||||
}, [cb, state, prevAccess])
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import {logger} from '#/ageAssurance/logger'
|
||||
|
||||
export enum AgeAssuranceAccess {
|
||||
Unknown = 'unknown',
|
||||
None = 'none',
|
||||
Safe = 'safe',
|
||||
Full = 'full',
|
||||
}
|
||||
|
||||
export enum AgeAssuranceStatus {
|
||||
Unknown = 'unknown',
|
||||
Pending = 'pending',
|
||||
Assured = 'assured',
|
||||
Blocked = 'blocked',
|
||||
}
|
||||
|
||||
export type AgeAssuranceState = {
|
||||
lastInitiatedAt?: string
|
||||
status: AgeAssuranceStatus
|
||||
access: AgeAssuranceAccess
|
||||
}
|
||||
|
||||
export function parseStatusFromString(raw: string) {
|
||||
switch (raw) {
|
||||
case 'unknown':
|
||||
return AgeAssuranceStatus.Unknown
|
||||
case 'pending':
|
||||
return AgeAssuranceStatus.Pending
|
||||
case 'assured':
|
||||
return AgeAssuranceStatus.Assured
|
||||
case 'blocked':
|
||||
return AgeAssuranceStatus.Blocked
|
||||
default:
|
||||
logger.error(`parseStatusFromString: unknown status value: ${raw}`)
|
||||
return AgeAssuranceStatus.Unknown
|
||||
}
|
||||
}
|
||||
|
||||
export function parseAccessFromString(raw: string) {
|
||||
switch (raw) {
|
||||
case 'unknown':
|
||||
return AgeAssuranceAccess.Unknown
|
||||
case 'none':
|
||||
return AgeAssuranceAccess.None
|
||||
case 'safe':
|
||||
return AgeAssuranceAccess.Safe
|
||||
case 'full':
|
||||
return AgeAssuranceAccess.Full
|
||||
default:
|
||||
logger.error(`parseAccessFromString: unknown access value: ${raw}`)
|
||||
return AgeAssuranceAccess.Full
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import {type AppBskyAgeassuranceBegin, AtpAgent} from '@atproto/api'
|
||||
import {useMutation} from '@tanstack/react-query'
|
||||
|
||||
import {wait} from '#/lib/async/wait'
|
||||
import {
|
||||
DEV_ENV_APPVIEW,
|
||||
PUBLIC_APPVIEW,
|
||||
PUBLIC_APPVIEW_DID,
|
||||
} from '#/lib/constants'
|
||||
import {isNetworkError} from '#/lib/hooks/useCleanError'
|
||||
import {logger} from '#/logger'
|
||||
import {useAgent} from '#/state/session'
|
||||
import {usePatchAgeAssuranceServerState} from '#/ageAssurance'
|
||||
import {BLUESKY_PROXY_DID} from '#/env'
|
||||
import {useGeolocation} from '#/geolocation'
|
||||
|
||||
const IS_DEV_ENV = BLUESKY_PROXY_DID !== PUBLIC_APPVIEW_DID
|
||||
const APPVIEW = IS_DEV_ENV ? DEV_ENV_APPVIEW : PUBLIC_APPVIEW
|
||||
|
||||
export function useBeginAgeAssurance() {
|
||||
const agent = useAgent()
|
||||
const geolocation = useGeolocation()
|
||||
const patchAgeAssuranceStateResponse = usePatchAgeAssuranceServerState()
|
||||
|
||||
return useMutation({
|
||||
async mutationFn(
|
||||
props: Omit<
|
||||
AppBskyAgeassuranceBegin.InputSchema,
|
||||
'countryCode' | 'regionCode'
|
||||
>,
|
||||
) {
|
||||
const countryCode = geolocation?.countryCode
|
||||
const regionCode = geolocation?.regionCode
|
||||
if (!countryCode) {
|
||||
throw new Error(`Geolocation not available, cannot init age assurance.`)
|
||||
}
|
||||
|
||||
const {
|
||||
data: {token},
|
||||
} = await agent.com.atproto.server.getServiceAuth({
|
||||
aud: BLUESKY_PROXY_DID,
|
||||
lxm: `app.bsky.ageassurance.begin`,
|
||||
})
|
||||
|
||||
const appView = new AtpAgent({service: APPVIEW})
|
||||
appView.sessionManager.session = {...agent.session!}
|
||||
appView.sessionManager.session.accessJwt = token
|
||||
appView.sessionManager.session.refreshJwt = ''
|
||||
|
||||
/*
|
||||
* 2s wait is good actually. Email sending takes a hot sec and this helps
|
||||
* ensure the email is ready for the user once they open their inbox.
|
||||
*/
|
||||
const {data} = await wait(
|
||||
2e3,
|
||||
appView.app.bsky.ageassurance.begin({
|
||||
...props,
|
||||
countryCode: countryCode.toUpperCase(),
|
||||
regionCode: regionCode ? regionCode.toUpperCase() : undefined,
|
||||
}),
|
||||
)
|
||||
|
||||
// Just keeps this in sync, not necessarily used right now
|
||||
patchAgeAssuranceStateResponse(data)
|
||||
},
|
||||
onError(e) {
|
||||
if (!isNetworkError(e)) {
|
||||
logger.error(`useBeginAgeAssurance failed`, {
|
||||
safeMessage: e,
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import {useCallback} from 'react'
|
||||
import {computeAgeAssuranceRegionAccess} from '@atproto/api'
|
||||
|
||||
import {useAgeAssuranceDataContext} from '#/ageAssurance/data'
|
||||
import {logger} from '#/ageAssurance/logger'
|
||||
import {AgeAssuranceAccess, parseAccessFromString} from '#/ageAssurance/types'
|
||||
import {getAgeAssuranceRegionConfigWithFallback} from '#/ageAssurance/util'
|
||||
import {type Geolocation} from '#/geolocation'
|
||||
|
||||
export function useComputeAgeAssuranceRegionAccess() {
|
||||
const {config, data} = useAgeAssuranceDataContext()
|
||||
return useCallback(
|
||||
(geolocation: Geolocation) => {
|
||||
if (!config) {
|
||||
logger.warn('useComputeAgeAssuranceRegionAccess: missing config')
|
||||
return AgeAssuranceAccess.Unknown
|
||||
}
|
||||
const region = getAgeAssuranceRegionConfigWithFallback(
|
||||
config,
|
||||
geolocation,
|
||||
)
|
||||
const result = computeAgeAssuranceRegionAccess(region, data)
|
||||
return result
|
||||
? parseAccessFromString(result.access)
|
||||
: AgeAssuranceAccess.Full
|
||||
},
|
||||
[config, data],
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import {useMemo} from 'react'
|
||||
import {
|
||||
ageAssuranceRuleIDs as ids,
|
||||
type AppBskyAgeassuranceDefs,
|
||||
getAgeAssuranceRegionConfig,
|
||||
} from '@atproto/api'
|
||||
|
||||
import {getAge} from '#/lib/strings/time'
|
||||
import {useAgeAssuranceDataContext} from '#/ageAssurance/data'
|
||||
import {AgeAssuranceAccess} from '#/ageAssurance/types'
|
||||
import {type Geolocation, useGeolocation} from '#/geolocation'
|
||||
|
||||
const DEFAULT_MIN_AGE = 13
|
||||
|
||||
/**
|
||||
* Get age assurance region config based on geolocation, with fallback to
|
||||
* app defaults if no region config is found.
|
||||
*
|
||||
* See {@link getAgeAssuranceRegionConfig} for the generic option, which can
|
||||
* return undefined if the geolocation does not match any AA region.
|
||||
*/
|
||||
export function getAgeAssuranceRegionConfigWithFallback(
|
||||
config: AppBskyAgeassuranceDefs.Config,
|
||||
geolocation: Geolocation,
|
||||
): AppBskyAgeassuranceDefs.ConfigRegion {
|
||||
const region = getAgeAssuranceRegionConfig(config, {
|
||||
countryCode: geolocation.countryCode ?? '',
|
||||
regionCode: geolocation.regionCode,
|
||||
})
|
||||
|
||||
return (
|
||||
region || {
|
||||
countryCode: '*',
|
||||
regionCode: undefined,
|
||||
rules: [
|
||||
{
|
||||
$type: ids.IfDeclaredOverAge,
|
||||
age: DEFAULT_MIN_AGE,
|
||||
access: AgeAssuranceAccess.Full,
|
||||
},
|
||||
{
|
||||
$type: ids.Default,
|
||||
access: AgeAssuranceAccess.None,
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get the age assurance region config based on current geolocation.
|
||||
* Does not fall-back to our app defaults. If no config is found, returns
|
||||
* undefined, which indicates no regional age assurance rules apply.
|
||||
*/
|
||||
export function useAgeAssuranceRegionConfig() {
|
||||
const geolocation = useGeolocation()
|
||||
const {config} = useAgeAssuranceDataContext()
|
||||
return useMemo(() => {
|
||||
if (!config) return
|
||||
// use generic helper, we want to potentially return undefined
|
||||
return getAgeAssuranceRegionConfig(config, {
|
||||
countryCode: geolocation.countryCode ?? '',
|
||||
regionCode: geolocation.regionCode,
|
||||
})
|
||||
}, [config, geolocation])
|
||||
}
|
||||
|
||||
/**
|
||||
* Some users may have erroneously set their birth date to the current date
|
||||
* if one wasn't set on their account. We previously didn't do validation on
|
||||
* the bday dialog, and it defaulted to the current date. This bug _has_ been
|
||||
* seen in production, so we need to check for it where possible.
|
||||
*/
|
||||
export function isLegacyBirthdateBug(birthDate: string) {
|
||||
return ['2025', '2024', '2023'].includes((birthDate || '').slice(0, 4))
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the user is under the minimum age required to use the app.
|
||||
* This applies to all regions.
|
||||
*/
|
||||
export function isUserUnderMinimumAge(birthDate: string) {
|
||||
return getAge(new Date(birthDate)) < DEFAULT_MIN_AGE
|
||||
}
|
||||
|
||||
export function isUserUnderAdultAge(birthDate: string) {
|
||||
return getAge(new Date(birthDate)) < 18
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
import {useEffect} from 'react'
|
||||
import {ScrollView, View} from 'react-native'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {useDeviceGeolocationApi} from '#/state/geolocation'
|
||||
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {DeviceLocationRequestDialog} from '#/components/dialogs/DeviceLocationRequestDialog'
|
||||
import {Divider} from '#/components/Divider'
|
||||
import {Full as Logo, Mark} from '#/components/icons/Logo'
|
||||
import {PinLocation_Stroke2_Corner0_Rounded as LocationIcon} from '#/components/icons/PinLocation'
|
||||
import {SimpleInlineLinkText as InlineLinkText} from '#/components/Link'
|
||||
import {Outlet as PortalOutlet} from '#/components/Portal'
|
||||
import * as Toast from '#/components/Toast'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {BottomSheetOutlet} from '#/../modules/bottom-sheet'
|
||||
|
||||
export function BlockedGeoOverlay() {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {gtPhone} = useBreakpoints()
|
||||
const insets = useSafeAreaInsets()
|
||||
const geoDialog = Dialog.useDialogControl()
|
||||
const {setDeviceGeolocation} = useDeviceGeolocationApi()
|
||||
|
||||
useEffect(() => {
|
||||
// just counting overall hits here
|
||||
logger.metric(`blockedGeoOverlay:shown`, {})
|
||||
}, [])
|
||||
|
||||
const textStyles = [a.text_md, a.leading_normal]
|
||||
const links = {
|
||||
blog: {
|
||||
to: `https://bsky.social/about/blog/08-22-2025-mississippi-hb1126`,
|
||||
label: _(msg`Read our blog post`),
|
||||
overridePresentation: false,
|
||||
disableMismatchWarning: true,
|
||||
style: textStyles,
|
||||
},
|
||||
}
|
||||
|
||||
const blocks = [
|
||||
_(msg`Unfortunately, Bluesky is unavailable in Mississippi right now.`),
|
||||
_(
|
||||
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.`,
|
||||
),
|
||||
_(
|
||||
msg`As a small team, we cannot justify building the expensive infrastructure this requirement demands while legal challenges to this law are pending.`,
|
||||
),
|
||||
_(
|
||||
msg`For now, we have made the difficult decision to block access to Bluesky in the state of Mississippi.`,
|
||||
),
|
||||
<>
|
||||
To learn more, read our{' '}
|
||||
<InlineLinkText {...links.blog}>blog post</InlineLinkText>.
|
||||
</>,
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<ScrollView
|
||||
contentContainerStyle={[
|
||||
a.px_2xl,
|
||||
{
|
||||
paddingTop: isWeb ? a.p_5xl.padding : insets.top + a.p_2xl.padding,
|
||||
paddingBottom: 100,
|
||||
},
|
||||
]}>
|
||||
<View
|
||||
style={[
|
||||
a.mx_auto,
|
||||
web({
|
||||
maxWidth: 380,
|
||||
paddingTop: gtPhone ? '8vh' : undefined,
|
||||
}),
|
||||
]}>
|
||||
<View style={[a.align_start]}>
|
||||
<View
|
||||
style={[
|
||||
a.pl_md,
|
||||
a.pr_lg,
|
||||
a.py_sm,
|
||||
a.rounded_full,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.gap_xs,
|
||||
{
|
||||
backgroundColor: t.palette.primary_25,
|
||||
},
|
||||
]}>
|
||||
<Mark fill={t.palette.primary_600} width={14} />
|
||||
<Text
|
||||
style={[
|
||||
a.font_semi_bold,
|
||||
{
|
||||
color: t.palette.primary_600,
|
||||
},
|
||||
]}>
|
||||
<Trans>Announcement</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={[a.gap_lg, {paddingTop: 32}]}>
|
||||
{blocks.map((block, index) => (
|
||||
<Text key={index} style={[textStyles]}>
|
||||
{block}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{!isWeb && (
|
||||
<>
|
||||
<View style={[a.pt_2xl]}>
|
||||
<Divider />
|
||||
</View>
|
||||
|
||||
<View style={[a.mt_xl, a.align_start]}>
|
||||
<Text style={[a.text_lg, a.font_bold, a.leading_snug, a.pb_xs]}>
|
||||
<Trans>Not in Mississippi?</Trans>
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
a.text_sm,
|
||||
a.leading_snug,
|
||||
t.atoms.text_contrast_medium,
|
||||
a.pb_md,
|
||||
]}>
|
||||
<Trans>
|
||||
Confirm your location with GPS. Your location data is not
|
||||
tracked and does not leave your device.
|
||||
</Trans>
|
||||
</Text>
|
||||
<Button
|
||||
label={_(msg`Confirm your location`)}
|
||||
onPress={() => geoDialog.open()}
|
||||
size="small"
|
||||
color="primary_subtle">
|
||||
<ButtonIcon icon={LocationIcon} />
|
||||
<ButtonText>
|
||||
<Trans>Confirm your location</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
<DeviceLocationRequestDialog
|
||||
control={geoDialog}
|
||||
onLocationAcquired={props => {
|
||||
if (props.geolocationStatus.isAgeBlockedGeo) {
|
||||
props.disableDialogAction()
|
||||
props.setDialogError(
|
||||
_(
|
||||
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.`,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
props.closeDialog(() => {
|
||||
// set this after close!
|
||||
setDeviceGeolocation({
|
||||
countryCode: props.geolocationStatus.countryCode,
|
||||
regionCode: props.geolocationStatus.regionCode,
|
||||
})
|
||||
Toast.show(_(msg`Thanks! You're all set.`), {
|
||||
type: 'success',
|
||||
})
|
||||
})
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<View style={[{paddingTop: 48}]}>
|
||||
<Logo width={120} textFill={t.atoms.text.color} />
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
{/*
|
||||
* While this blocking overlay is up, other dialogs in the shell
|
||||
* are not mounted, so it _should_ be safe to use these here
|
||||
* without fear of other modals showing up.
|
||||
*/}
|
||||
<BottomSheetOutlet />
|
||||
<PortalOutlet />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -421,6 +421,7 @@ export function SimpleInlineLinkText({
|
||||
label,
|
||||
disableUnderline,
|
||||
shouldProxy,
|
||||
onPress: outerOnPress,
|
||||
...rest
|
||||
}: Omit<
|
||||
InlineLinkProps,
|
||||
@@ -428,7 +429,6 @@ export function SimpleInlineLinkText({
|
||||
| 'action'
|
||||
| 'disableMismatchWarning'
|
||||
| 'overridePresentation'
|
||||
| 'onPress'
|
||||
| 'onLongPress'
|
||||
| 'shareOnLongPress'
|
||||
> & {
|
||||
@@ -448,7 +448,9 @@ export function SimpleInlineLinkText({
|
||||
href = createProxiedUrl(href)
|
||||
}
|
||||
|
||||
const onPress = () => {
|
||||
const onPress = (e: GestureResponderEvent) => {
|
||||
const exitEarlyIfFalse = outerOnPress?.(e)
|
||||
if (exitEarlyIfFalse === false) return
|
||||
Linking.openURL(href)
|
||||
}
|
||||
|
||||
@@ -517,7 +519,7 @@ export function WebOnlyInlineLinkText({
|
||||
export function createStaticClick(
|
||||
onPressHandler: Exclude<BaseLinkProps['onPress'], undefined>,
|
||||
): {
|
||||
to: BaseLinkProps['to']
|
||||
to: string
|
||||
onPress: Exclude<BaseLinkProps['onPress'], undefined>
|
||||
} {
|
||||
return {
|
||||
|
||||
@@ -11,7 +11,6 @@ import {shareText, shareUrl} from '#/lib/sharing'
|
||||
import {toShareUrl} from '#/lib/strings/url-helpers'
|
||||
import {logger} from '#/logger'
|
||||
import {isIOS} from '#/platform/detection'
|
||||
import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance'
|
||||
import {useProfileShadow} from '#/state/cache/profile-shadow'
|
||||
import {useSession} from '#/state/session'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
@@ -24,6 +23,7 @@ import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/i
|
||||
import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard'
|
||||
import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlaneIcon} from '#/components/icons/PaperPlane'
|
||||
import * as Menu from '#/components/Menu'
|
||||
import {useAgeAssurance} from '#/ageAssurance'
|
||||
import {useDevMode} from '#/storage/hooks/dev-mode'
|
||||
import {RecentChats} from './RecentChats'
|
||||
import {type ShareMenuItemsProps} from './ShareMenuItems.types'
|
||||
@@ -37,7 +37,7 @@ let ShareMenuItems = ({
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const sendViaChatControl = useDialogControl()
|
||||
const [devModeEnabled] = useDevMode()
|
||||
const {isAgeRestricted} = useAgeAssurance()
|
||||
const aa = useAgeAssurance()
|
||||
|
||||
const postUri = post.uri
|
||||
const postAuthor = useProfileShadow(post.author)
|
||||
@@ -91,7 +91,7 @@ let ShareMenuItems = ({
|
||||
return (
|
||||
<>
|
||||
<Menu.Outer>
|
||||
{hasSession && !isAgeRestricted && (
|
||||
{hasSession && aa.state.access === aa.Access.Full && (
|
||||
<Menu.Group>
|
||||
<Menu.ContainerItem>
|
||||
<RecentChats postUri={postUri} />
|
||||
|
||||
@@ -10,7 +10,6 @@ import {shareText, shareUrl} from '#/lib/sharing'
|
||||
import {toShareUrl} from '#/lib/strings/url-helpers'
|
||||
import {logger} from '#/logger'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance'
|
||||
import {useProfileShadow} from '#/state/cache/profile-shadow'
|
||||
import {useSession} from '#/state/session'
|
||||
import {useBreakpoints} from '#/alf'
|
||||
@@ -22,6 +21,7 @@ import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/i
|
||||
import {CodeBrackets_Stroke2_Corner0_Rounded as CodeBracketsIcon} from '#/components/icons/CodeBrackets'
|
||||
import {PaperPlane_Stroke2_Corner0_Rounded as Send} from '#/components/icons/PaperPlane'
|
||||
import * as Menu from '#/components/Menu'
|
||||
import {useAgeAssurance} from '#/ageAssurance'
|
||||
import {useDevMode} from '#/storage/hooks/dev-mode'
|
||||
import {type ShareMenuItemsProps} from './ShareMenuItems.types'
|
||||
|
||||
@@ -38,7 +38,7 @@ let ShareMenuItems = ({
|
||||
const embedPostControl = useDialogControl()
|
||||
const sendViaChatControl = useDialogControl()
|
||||
const [devModeEnabled] = useDevMode()
|
||||
const {isAgeRestricted} = useAgeAssurance()
|
||||
const aa = useAgeAssurance()
|
||||
|
||||
const postUri = post.uri
|
||||
const postCid = post.cid
|
||||
@@ -97,7 +97,7 @@ let ShareMenuItems = ({
|
||||
<Menu.Outer>
|
||||
{!hideInPWI && copyLinkItem}
|
||||
|
||||
{hasSession && !isAgeRestricted && (
|
||||
{hasSession && aa.state.access === aa.Access.Full && (
|
||||
<Menu.Item
|
||||
testID="postDropdownSendViaDMBtn"
|
||||
label={_(msg`Send via direct message`)}
|
||||
|
||||
@@ -4,9 +4,6 @@ import {useLingui} from '@lingui/react'
|
||||
|
||||
import {dateDiff, useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance'
|
||||
import {logger} from '#/state/ageAssurance/util'
|
||||
import {useDeviceGeolocationApi} from '#/state/geolocation'
|
||||
import {atoms as a, useBreakpoints, useTheme, type ViewStyleProp} from '#/alf'
|
||||
import {Admonition} from '#/components/Admonition'
|
||||
import {AgeAssuranceAppealDialog} from '#/components/ageAssurance/AgeAssuranceAppealDialog'
|
||||
@@ -23,14 +20,13 @@ import {Divider} from '#/components/Divider'
|
||||
import {createStaticClick, InlineLinkText} from '#/components/Link'
|
||||
import * as Toast from '#/components/Toast'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {logger, useAgeAssurance} from '#/ageAssurance'
|
||||
import {useComputeAgeAssuranceRegionAccess} from '#/ageAssurance/useComputeAgeAssuranceRegionAccess'
|
||||
import {useDeviceGeolocationApi} from '#/geolocation'
|
||||
|
||||
export function AgeAssuranceAccountCard({style}: ViewStyleProp & {}) {
|
||||
const {isReady, isAgeRestricted, isDeclaredUnderage} = useAgeAssurance()
|
||||
|
||||
if (!isReady) return null
|
||||
if (isDeclaredUnderage) return null
|
||||
if (!isAgeRestricted) return null
|
||||
|
||||
const aa = useAgeAssurance()
|
||||
if (aa.state.access === aa.Access.Full) return null
|
||||
return <Inner style={style} />
|
||||
}
|
||||
|
||||
@@ -43,10 +39,12 @@ function Inner({style}: ViewStyleProp & {}) {
|
||||
const getTimeAgo = useGetTimeAgo()
|
||||
const {gtPhone} = useBreakpoints()
|
||||
const {setDeviceGeolocation} = useDeviceGeolocationApi()
|
||||
const computeAgeAssuranceRegionAccess = useComputeAgeAssuranceRegionAccess()
|
||||
|
||||
const copy = useAgeAssuranceCopy()
|
||||
const {status, lastInitiatedAt} = useAgeAssurance()
|
||||
const isBlocked = status === 'blocked'
|
||||
const aa = useAgeAssurance()
|
||||
const {status, lastInitiatedAt} = aa.state
|
||||
const isBlocked = status === aa.Status.Blocked
|
||||
const hasInitiated = !!lastInitiatedAt
|
||||
const timeAgo = lastInitiatedAt
|
||||
? getTimeAgo(lastInitiatedAt, new Date())
|
||||
@@ -98,7 +96,10 @@ function Inner({style}: ViewStyleProp & {}) {
|
||||
<DeviceLocationRequestDialog
|
||||
control={locationControl}
|
||||
onLocationAcquired={props => {
|
||||
if (props.geolocationStatus.isAgeRestrictedGeo) {
|
||||
const access = computeAgeAssuranceRegionAccess(
|
||||
props.geolocation,
|
||||
)
|
||||
if (access !== aa.Access.Full) {
|
||||
props.disableDialogAction()
|
||||
props.setDialogError(
|
||||
_(
|
||||
@@ -108,10 +109,7 @@ function Inner({style}: ViewStyleProp & {}) {
|
||||
} else {
|
||||
props.closeDialog(() => {
|
||||
// set this after close!
|
||||
setDeviceGeolocation({
|
||||
countryCode: props.geolocationStatus.countryCode,
|
||||
regionCode: props.geolocationStatus.regionCode,
|
||||
})
|
||||
setDeviceGeolocation(props.geolocation)
|
||||
Toast.show(_(msg`Thanks! You're all set.`), {
|
||||
type: 'success',
|
||||
})
|
||||
|
||||
@@ -2,25 +2,23 @@ import {View} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance'
|
||||
import {logger} from '#/state/ageAssurance/util'
|
||||
import {atoms as a, select, useTheme, type ViewStyleProp} from '#/alf'
|
||||
import {useDialogControl} from '#/components/ageAssurance/AgeAssuranceInitDialog'
|
||||
import type * as Dialog from '#/components/Dialog'
|
||||
import {ShieldCheck_Stroke2_Corner0_Rounded as Shield} from '#/components/icons/Shield'
|
||||
import {InlineLinkText} from '#/components/Link'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {useAgeAssurance} from '#/ageAssurance'
|
||||
import {logger} from '#/ageAssurance'
|
||||
|
||||
export function AgeAssuranceAdmonition({
|
||||
children,
|
||||
style,
|
||||
}: ViewStyleProp & {children: React.ReactNode}) {
|
||||
const control = useDialogControl()
|
||||
const {isReady, isDeclaredUnderage, isAgeRestricted} = useAgeAssurance()
|
||||
const aa = useAgeAssurance()
|
||||
|
||||
if (!isReady) return null
|
||||
if (isDeclaredUnderage) return null
|
||||
if (!isAgeRestricted) return null
|
||||
if (aa.state.access === aa.Access.Full) return null
|
||||
|
||||
return (
|
||||
<Inner style={style} control={control}>
|
||||
|
||||
@@ -6,7 +6,6 @@ import {useLingui} from '@lingui/react'
|
||||
import {useMutation} from '@tanstack/react-query'
|
||||
|
||||
import {BLUESKY_MOD_SERVICE_HEADERS} from '#/lib/constants'
|
||||
import {logger} from '#/state/ageAssurance/util'
|
||||
import {useAgent, useSession} from '#/state/session'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {atoms as a, useBreakpoints, web} from '#/alf'
|
||||
@@ -15,6 +14,7 @@ import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {logger} from '#/ageAssurance'
|
||||
|
||||
export function AgeAssuranceAppealDialog({
|
||||
control,
|
||||
|
||||
@@ -3,8 +3,6 @@ import {View} from 'react-native'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance'
|
||||
import {logger} from '#/state/ageAssurance/util'
|
||||
import {Nux, useNux, useSaveNux} from '#/state/queries/nuxs'
|
||||
import {atoms as a, select, useTheme} from '#/alf'
|
||||
import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy'
|
||||
@@ -13,30 +11,22 @@ import {ShieldCheck_Stroke2_Corner0_Rounded as Shield} from '#/components/icons/
|
||||
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
|
||||
import {Link} from '#/components/Link'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {useAgeAssurance} from '#/ageAssurance'
|
||||
import {logger} from '#/ageAssurance'
|
||||
|
||||
export function useInternalState() {
|
||||
const {isReady, isDeclaredUnderage, isAgeRestricted, lastInitiatedAt} =
|
||||
useAgeAssurance()
|
||||
const aa = useAgeAssurance()
|
||||
const {nux} = useNux(Nux.AgeAssuranceDismissibleFeedBanner)
|
||||
const {mutate: save, variables} = useSaveNux()
|
||||
const hidden = !!variables
|
||||
|
||||
const visible = useMemo(() => {
|
||||
if (!isReady) return false
|
||||
if (isDeclaredUnderage) return false
|
||||
if (!isAgeRestricted) return false
|
||||
if (lastInitiatedAt) return false
|
||||
if (aa.state.access === aa.Access.Full) return false
|
||||
if (aa.state.lastInitiatedAt) return false
|
||||
if (hidden) return false
|
||||
if (nux && nux.completed) return false
|
||||
return true
|
||||
}, [
|
||||
isReady,
|
||||
isDeclaredUnderage,
|
||||
isAgeRestricted,
|
||||
lastInitiatedAt,
|
||||
hidden,
|
||||
nux,
|
||||
])
|
||||
}, [aa, hidden, nux])
|
||||
|
||||
const close = () => {
|
||||
save({
|
||||
|
||||
@@ -2,28 +2,25 @@ import {View} from 'react-native'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance'
|
||||
import {logger} from '#/state/ageAssurance/util'
|
||||
import {Nux, useNux, useSaveNux} from '#/state/queries/nuxs'
|
||||
import {atoms as a, type ViewStyleProp} from '#/alf'
|
||||
import {AgeAssuranceAdmonition} from '#/components/ageAssurance/AgeAssuranceAdmonition'
|
||||
import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy'
|
||||
import {Button, ButtonIcon} from '#/components/Button'
|
||||
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
|
||||
import {useAgeAssurance} from '#/ageAssurance'
|
||||
import {logger} from '#/ageAssurance'
|
||||
|
||||
export function AgeAssuranceDismissibleNotice({style}: ViewStyleProp & {}) {
|
||||
const {_} = useLingui()
|
||||
const {isReady, isDeclaredUnderage, isAgeRestricted, lastInitiatedAt} =
|
||||
useAgeAssurance()
|
||||
const aa = useAgeAssurance()
|
||||
const {nux} = useNux(Nux.AgeAssuranceDismissibleNotice)
|
||||
const copy = useAgeAssuranceCopy()
|
||||
const {mutate: save, variables} = useSaveNux()
|
||||
const hidden = !!variables
|
||||
|
||||
if (!isReady) return null
|
||||
if (isDeclaredUnderage) return null
|
||||
if (!isAgeRestricted) return null
|
||||
if (lastInitiatedAt) return null
|
||||
if (aa.state.access === aa.Access.Full) return null
|
||||
if (aa.state.lastInitiatedAt) return null
|
||||
if (hidden) return null
|
||||
if (nux && nux.completed) return null
|
||||
|
||||
|
||||
@@ -14,9 +14,6 @@ import {useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
|
||||
import {useTLDs} from '#/lib/hooks/useTLDs'
|
||||
import {isEmailMaybeInvalid} from '#/lib/strings/email'
|
||||
import {type AppLanguage} from '#/locale/languages'
|
||||
import {useAgeAssuranceContext} from '#/state/ageAssurance'
|
||||
import {useInitAgeAssurance} from '#/state/ageAssurance/useInitAgeAssurance'
|
||||
import {logger} from '#/state/ageAssurance/util'
|
||||
import {useLanguagePrefs} from '#/state/preferences'
|
||||
import {useSession} from '#/state/session'
|
||||
import {atoms as a, useTheme, web} from '#/alf'
|
||||
@@ -30,9 +27,12 @@ import {Divider} from '#/components/Divider'
|
||||
import * as TextField from '#/components/forms/TextField'
|
||||
import {ShieldCheck_Stroke2_Corner0_Rounded as Shield} from '#/components/icons/Shield'
|
||||
import {LanguageSelect} from '#/components/LanguageSelect'
|
||||
import {InlineLinkText} from '#/components/Link'
|
||||
import {SimpleInlineLinkText} from '#/components/Link'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {logger} from '#/ageAssurance'
|
||||
import {useAgeAssurance} from '#/ageAssurance'
|
||||
import {useBeginAgeAssurance} from '#/ageAssurance/useBeginAgeAssurance'
|
||||
|
||||
export {useDialogControl} from '#/components/Dialog/context'
|
||||
|
||||
@@ -69,7 +69,8 @@ function Inner() {
|
||||
const langPrefs = useLanguagePrefs()
|
||||
const cleanError = useCleanError()
|
||||
const {close} = Dialog.useDialogContext()
|
||||
const {lastInitiatedAt} = useAgeAssuranceContext()
|
||||
const aa = useAgeAssurance()
|
||||
const lastInitiatedAt = aa.state.lastInitiatedAt
|
||||
const getTimeAgo = useGetTimeAgo()
|
||||
const tlds = useTLDs()
|
||||
const createSupportLink = useCreateSupportLink()
|
||||
@@ -88,7 +89,7 @@ function Inner() {
|
||||
)
|
||||
const [error, setError] = useState<React.ReactNode>(null)
|
||||
|
||||
const {mutateAsync: init, isPending} = useInitAgeAssurance()
|
||||
const {mutateAsync: begin, isPending} = useBeginAgeAssurance()
|
||||
|
||||
const runEmailValidation = () => {
|
||||
if (validateEmail(email)) {
|
||||
@@ -127,7 +128,7 @@ function Inner() {
|
||||
return
|
||||
}
|
||||
|
||||
await init({
|
||||
await begin({
|
||||
email,
|
||||
language,
|
||||
})
|
||||
@@ -150,11 +151,11 @@ function Inner() {
|
||||
<Trans>
|
||||
We're having issues initializing the age assurance process for
|
||||
your account. Please{' '}
|
||||
<InlineLinkText
|
||||
<SimpleInlineLinkText
|
||||
to={createSupportLink({code: SupportCode.AA_DID, email})}
|
||||
label={_(msg`Contact support`)}>
|
||||
contact support
|
||||
</InlineLinkText>{' '}
|
||||
</SimpleInlineLinkText>{' '}
|
||||
for assistance.
|
||||
</Trans>
|
||||
</>
|
||||
@@ -195,14 +196,12 @@ function Inner() {
|
||||
<Text style={[a.text_sm, a.leading_snug]}>
|
||||
<Trans>
|
||||
We have partnered with{' '}
|
||||
<InlineLinkText
|
||||
overridePresentation
|
||||
disableMismatchWarning
|
||||
<SimpleInlineLinkText
|
||||
label={_(msg`KWS website`)}
|
||||
to={urls.kwsHome}
|
||||
style={[a.text_sm, a.leading_snug]}>
|
||||
KWS
|
||||
</InlineLinkText>{' '}
|
||||
</SimpleInlineLinkText>{' '}
|
||||
to verify that you’re an adult. When you click "Begin" below,
|
||||
KWS will check if you have previously verified your age using
|
||||
this email address for other games/services powered by KWS
|
||||
@@ -328,24 +327,20 @@ function Inner() {
|
||||
style={[a.text_xs, a.leading_snug, t.atoms.text_contrast_medium]}>
|
||||
<Trans>
|
||||
By continuing, you agree to the{' '}
|
||||
<InlineLinkText
|
||||
overridePresentation
|
||||
disableMismatchWarning
|
||||
<SimpleInlineLinkText
|
||||
label={_(msg`KWS Terms of Use`)}
|
||||
to={urls.kwsTermsOfUse}
|
||||
style={[a.text_xs, a.leading_snug]}>
|
||||
KWS Terms of Use
|
||||
</InlineLinkText>{' '}
|
||||
</SimpleInlineLinkText>{' '}
|
||||
and acknowledge that KWS will store your verified status with
|
||||
your hashed email address in accordance with the{' '}
|
||||
<InlineLinkText
|
||||
overridePresentation
|
||||
disableMismatchWarning
|
||||
<SimpleInlineLinkText
|
||||
label={_(msg`KWS Privacy Policy`)}
|
||||
to={urls.kwsPrivacyPolicy}
|
||||
style={[a.text_xs, a.leading_snug]}>
|
||||
KWS Privacy Policy
|
||||
</InlineLinkText>
|
||||
</SimpleInlineLinkText>
|
||||
. This means you won’t need to verify again the next time you
|
||||
use this email for other apps, games, and services powered by
|
||||
KWS technology.
|
||||
|
||||
@@ -6,8 +6,6 @@ import {useLingui} from '@lingui/react'
|
||||
import {retry} from '#/lib/async/retry'
|
||||
import {wait} from '#/lib/async/wait'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {useAgeAssuranceAPIContext} from '#/state/ageAssurance'
|
||||
import {logger} from '#/state/ageAssurance/util'
|
||||
import {useAgent} from '#/state/session'
|
||||
import {atoms as a, useTheme, web} from '#/alf'
|
||||
import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge'
|
||||
@@ -18,6 +16,8 @@ import {CheckThick_Stroke2_Corner0_Rounded as SuccessIcon} from '#/components/ic
|
||||
import {CircleInfo_Stroke2_Corner0_Rounded as ErrorIcon} from '#/components/icons/CircleInfo'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {refetchAgeAssuranceServerState} from '#/ageAssurance'
|
||||
import {logger} from '#/ageAssurance'
|
||||
|
||||
export type AgeAssuranceRedirectDialogState = {
|
||||
result: 'success' | 'unknown'
|
||||
@@ -63,7 +63,7 @@ export function AgeAssuranceRedirectDialog() {
|
||||
const {_} = useLingui()
|
||||
const control = useAgeAssuranceRedirectDialogControl()
|
||||
|
||||
// TODO for testing
|
||||
// for testing
|
||||
// Dialog.useAutoOpen(control.control, 3e3)
|
||||
|
||||
return (
|
||||
@@ -88,7 +88,6 @@ export function Inner({}: {optimisticState?: AgeAssuranceRedirectDialogState}) {
|
||||
const control = useAgeAssuranceRedirectDialogControl()
|
||||
const [error, setError] = useState(false)
|
||||
const [success, setSuccess] = useState(false)
|
||||
const {refetch: refreshAgeAssuranceState} = useAgeAssuranceAPIContext()
|
||||
|
||||
useEffect(() => {
|
||||
if (polling.current) return
|
||||
@@ -106,9 +105,9 @@ export function Inner({}: {optimisticState?: AgeAssuranceRedirectDialogState}) {
|
||||
if (!agent.session) return
|
||||
if (unmounted.current) return
|
||||
|
||||
const {data} = await agent.app.bsky.unspecced.getAgeAssuranceState()
|
||||
const data = await refetchAgeAssuranceServerState({agent})
|
||||
|
||||
if (data.status !== 'assured') {
|
||||
if (data?.state.status !== 'assured') {
|
||||
throw new Error(
|
||||
`Polling for age assurance state did not receive assured status`,
|
||||
)
|
||||
@@ -124,9 +123,6 @@ export function Inner({}: {optimisticState?: AgeAssuranceRedirectDialogState}) {
|
||||
if (!agent.session) return
|
||||
if (unmounted.current) return
|
||||
|
||||
// success! update state
|
||||
await refreshAgeAssuranceState()
|
||||
|
||||
setSuccess(true)
|
||||
|
||||
logger.metric('ageAssurance:redirectDialogSuccess', {})
|
||||
@@ -134,15 +130,13 @@ export function Inner({}: {optimisticState?: AgeAssuranceRedirectDialogState}) {
|
||||
.catch(() => {
|
||||
if (unmounted.current) return
|
||||
setError(true)
|
||||
// try a refetch anyway
|
||||
refreshAgeAssuranceState()
|
||||
logger.metric('ageAssurance:redirectDialogFail', {})
|
||||
})
|
||||
|
||||
return () => {
|
||||
unmounted.current = true
|
||||
}
|
||||
}, [agent, control, refreshAgeAssuranceState])
|
||||
}, [agent, control])
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
|
||||
@@ -2,8 +2,6 @@ import {View} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance'
|
||||
import {logger} from '#/state/ageAssurance/util'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {Admonition} from '#/components/Admonition'
|
||||
import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge'
|
||||
@@ -13,6 +11,8 @@ import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components
|
||||
import * as Layout from '#/components/Layout'
|
||||
import {Link} from '#/components/Link'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {useAgeAssurance} from '#/ageAssurance'
|
||||
import {logger} from '#/ageAssurance'
|
||||
|
||||
export function AgeRestrictedScreen({
|
||||
children,
|
||||
@@ -27,22 +27,9 @@ export function AgeRestrictedScreen({
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const copy = useAgeAssuranceCopy()
|
||||
const {isReady, isAgeRestricted} = useAgeAssurance()
|
||||
const aa = useAgeAssurance()
|
||||
|
||||
if (!isReady) {
|
||||
return (
|
||||
<Layout.Screen>
|
||||
<Layout.Header.Outer>
|
||||
<Layout.Header.Content>
|
||||
<Layout.Header.TitleText> </Layout.Header.TitleText>
|
||||
</Layout.Header.Content>
|
||||
<Layout.Header.Slot />
|
||||
</Layout.Header.Outer>
|
||||
<Layout.Content />
|
||||
</Layout.Screen>
|
||||
)
|
||||
}
|
||||
if (!isAgeRestricted) return children
|
||||
if (aa.state.access === aa.Access.Full) return children
|
||||
|
||||
return (
|
||||
<Layout.Screen>
|
||||
|
||||
@@ -8,7 +8,7 @@ export function useAgeAssuranceCopy() {
|
||||
return useMemo(() => {
|
||||
return {
|
||||
notice: _(
|
||||
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.`,
|
||||
msg`Due to laws in your region, certain features on Bluesky are currently restricted until you're able to verify you're an adult.`,
|
||||
),
|
||||
banner: _(
|
||||
msg`The laws in your location require you to verify you're an adult to access certain features. Tap to learn more.`,
|
||||
|
||||
@@ -7,10 +7,13 @@ import {cleanError} from '#/lib/strings/errors'
|
||||
import {getAge, getDateAgo} from '#/lib/strings/time'
|
||||
import {logger} from '#/logger'
|
||||
import {isIOS, isWeb} from '#/platform/detection'
|
||||
import {
|
||||
useBirthdateMutation,
|
||||
useIsBirthdateUpdateAllowed,
|
||||
} from '#/state/birthdate'
|
||||
import {
|
||||
usePreferencesQuery,
|
||||
type UsePreferencesQueryResponse,
|
||||
usePreferencesSetBirthDateMutation,
|
||||
} from '#/state/queries/preferences'
|
||||
import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
|
||||
import {atoms as a, useTheme, web} from '#/alf'
|
||||
@@ -18,7 +21,7 @@ import {Admonition} from '#/components/Admonition'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {DateField} from '#/components/forms/DateField'
|
||||
import {InlineLinkText} from '#/components/Link'
|
||||
import {SimpleInlineLinkText} from '#/components/Link'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
@@ -30,42 +33,71 @@ export function BirthDateSettingsDialog({
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {isLoading, error, data: preferences} = usePreferencesQuery()
|
||||
const isBirthdateUpdateAllowed = useIsBirthdateUpdateAllowed()
|
||||
|
||||
return (
|
||||
<Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}>
|
||||
<Dialog.Handle />
|
||||
<Dialog.ScrollableInner
|
||||
label={_(msg`My Birthday`)}
|
||||
style={web({maxWidth: 400})}>
|
||||
<View style={[a.gap_sm]}>
|
||||
<Text style={[a.text_xl, a.font_semi_bold]}>
|
||||
<Trans>My Birthday</Trans>
|
||||
</Text>
|
||||
<Text style={[a.leading_snug, t.atoms.text_contrast_medium]}>
|
||||
<Trans>
|
||||
This information is private and not shared with other users.
|
||||
</Trans>
|
||||
</Text>
|
||||
{isBirthdateUpdateAllowed ? (
|
||||
<Dialog.ScrollableInner
|
||||
label={_(msg`My Birthdate`)}
|
||||
style={web({maxWidth: 400})}>
|
||||
<View style={[a.gap_md]}>
|
||||
<Text style={[a.text_xl, a.font_semi_bold]}>
|
||||
<Trans>My Birthdate</Trans>
|
||||
</Text>
|
||||
<Text
|
||||
style={[a.text_md, a.leading_snug, t.atoms.text_contrast_medium]}>
|
||||
<Trans>
|
||||
This information is private and not shared with other users.
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
{isLoading ? (
|
||||
<Loader size="xl" />
|
||||
) : error || !preferences ? (
|
||||
<ErrorMessage
|
||||
message={
|
||||
error?.toString() ||
|
||||
_(
|
||||
msg`We were unable to load your birth date preferences. Please try again.`,
|
||||
)
|
||||
}
|
||||
style={[a.rounded_sm]}
|
||||
/>
|
||||
) : (
|
||||
<BirthdayInner control={control} preferences={preferences} />
|
||||
)}
|
||||
</View>
|
||||
{isLoading ? (
|
||||
<Loader size="xl" />
|
||||
) : error || !preferences ? (
|
||||
<ErrorMessage
|
||||
message={
|
||||
error?.toString() ||
|
||||
_(
|
||||
msg`We were unable to load your birthdate preferences. Please try again.`,
|
||||
)
|
||||
}
|
||||
style={[a.rounded_sm]}
|
||||
/>
|
||||
) : (
|
||||
<BirthdayInner control={control} preferences={preferences} />
|
||||
)}
|
||||
</View>
|
||||
|
||||
<Dialog.Close />
|
||||
</Dialog.ScrollableInner>
|
||||
<Dialog.Close />
|
||||
</Dialog.ScrollableInner>
|
||||
) : (
|
||||
<Dialog.ScrollableInner
|
||||
label={_(msg`You recently changed your birthdate`)}
|
||||
style={web({maxWidth: 400})}>
|
||||
<View style={[a.gap_sm]}>
|
||||
<Text
|
||||
style={[
|
||||
a.text_xl,
|
||||
a.font_semi_bold,
|
||||
a.leading_snug,
|
||||
{paddingRight: 32},
|
||||
]}>
|
||||
<Trans>You recently changed your birthdate</Trans>
|
||||
</Text>
|
||||
<Text
|
||||
style={[a.text_md, a.leading_snug, t.atoms.text_contrast_medium]}>
|
||||
<Trans>
|
||||
There is a limit to how often you can change your birthdate. You
|
||||
may need to wait a day or two before updating it again.
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Dialog.Close />
|
||||
</Dialog.ScrollableInner>
|
||||
)}
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
@@ -86,7 +118,7 @@ function BirthdayInner({
|
||||
isError,
|
||||
error,
|
||||
mutateAsync: setBirthDate,
|
||||
} = usePreferencesSetBirthDateMutation()
|
||||
} = useBirthdateMutation()
|
||||
const hasChanged = date !== preferences.birthDate
|
||||
|
||||
const age = getAge(new Date(date))
|
||||
@@ -112,8 +144,8 @@ function BirthdayInner({
|
||||
testID="birthdayInput"
|
||||
value={date}
|
||||
onChangeDate={newDate => setDate(new Date(newDate))}
|
||||
label={_(msg`Birthday`)}
|
||||
accessibilityHint={_(msg`Enter your birth date`)}
|
||||
label={_(msg`Birthdate`)}
|
||||
accessibilityHint={_(msg`Enter your birthdate`)}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -130,11 +162,11 @@ function BirthdayInner({
|
||||
<Admonition type="error">
|
||||
<Trans>
|
||||
You must be at least 13 years old to use Bluesky. Read our{' '}
|
||||
<InlineLinkText
|
||||
<SimpleInlineLinkText
|
||||
to="https://bsky.social/about/support/tos"
|
||||
label={_(msg`Terms of Service`)}>
|
||||
Terms of Service
|
||||
</InlineLinkText>{' '}
|
||||
</SimpleInlineLinkText>{' '}
|
||||
for more information.
|
||||
</Trans>
|
||||
</Admonition>
|
||||
@@ -146,7 +178,7 @@ function BirthdayInner({
|
||||
|
||||
<View style={isWeb && [a.flex_row, a.justify_end]}>
|
||||
<Button
|
||||
label={hasChanged ? _(msg`Save birthday`) : _(msg`Done`)}
|
||||
label={hasChanged ? _(msg`Save birthdate`) : _(msg`Done`)}
|
||||
size="large"
|
||||
onPress={onSave}
|
||||
variant="solid"
|
||||
|
||||
@@ -7,12 +7,6 @@ import {wait} from '#/lib/async/wait'
|
||||
import {isNetworkError, useCleanError} from '#/lib/hooks/useCleanError'
|
||||
import {logger} from '#/logger'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {
|
||||
computeGeolocationStatus,
|
||||
type GeolocationStatus,
|
||||
useGeolocationConfig,
|
||||
} from '#/state/geolocation'
|
||||
import {useRequestDeviceLocation} from '#/state/geolocation/useRequestDeviceLocation'
|
||||
import {atoms as a, useTheme, web} from '#/alf'
|
||||
import {Admonition} from '#/components/Admonition'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
@@ -20,10 +14,11 @@ import * as Dialog from '#/components/Dialog'
|
||||
import {PinLocation_Stroke2_Corner0_Rounded as LocationIcon} from '#/components/icons/PinLocation'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {type Geolocation, useRequestDeviceGeolocation} from '#/geolocation'
|
||||
|
||||
export type Props = {
|
||||
onLocationAcquired?: (props: {
|
||||
geolocationStatus: GeolocationStatus
|
||||
geolocation: Geolocation
|
||||
setDialogError: (error: string) => void
|
||||
disableDialogAction: () => void
|
||||
closeDialog: (callback?: () => void) => void
|
||||
@@ -57,8 +52,7 @@ function DeviceLocationRequestDialogInner({onLocationAcquired}: Props) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {close} = Dialog.useDialogContext()
|
||||
const requestDeviceLocation = useRequestDeviceLocation()
|
||||
const {config} = useGeolocationConfig()
|
||||
const requestDeviceLocation = useRequestDeviceGeolocation()
|
||||
const cleanError = useCleanError()
|
||||
|
||||
const [isRequesting, setIsRequesting] = useState(false)
|
||||
@@ -76,9 +70,8 @@ function DeviceLocationRequestDialogInner({onLocationAcquired}: Props) {
|
||||
const location = req.location
|
||||
|
||||
if (location && location.countryCode) {
|
||||
const geolocationStatus = computeGeolocationStatus(location, config)
|
||||
onLocationAcquired?.({
|
||||
geolocationStatus,
|
||||
geolocation: location,
|
||||
setDialogError: setError,
|
||||
disableDialogAction: () => setDialogDisabled(true),
|
||||
closeDialog: close,
|
||||
|
||||
Vendored
+5
-8
@@ -100,14 +100,11 @@ export const GCP_PROJECT_ID: number =
|
||||
: Number(process.env.EXPO_PUBLIC_GCP_PROJECT_ID)
|
||||
|
||||
/**
|
||||
* URL for the bapp-config web worker _development_ environment. Can be a
|
||||
* URLs for the app config web worker. Can be a
|
||||
* locally running server, see `env.example` for more.
|
||||
*/
|
||||
export const BAPP_CONFIG_DEV_URL = process.env.BAPP_CONFIG_DEV_URL
|
||||
|
||||
/**
|
||||
* Dev environment passthrough value for bapp-config web worker. Allows local
|
||||
* dev access to the web worker running in `development` mode.
|
||||
*/
|
||||
export const BAPP_CONFIG_DEV_BYPASS_SECRET: string =
|
||||
process.env.BAPP_CONFIG_DEV_BYPASS_SECRET
|
||||
export const BAPP_CONFIG_PROD_URL = `https://ip.bsky.app`
|
||||
export const BAPP_CONFIG_URL = IS_DEV
|
||||
? (BAPP_CONFIG_DEV_URL ?? BAPP_CONFIG_PROD_URL)
|
||||
: BAPP_CONFIG_PROD_URL
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import {BAPP_CONFIG_URL} from '#/env'
|
||||
import {type Geolocation} from '#/geolocation/types'
|
||||
|
||||
export const GEOLOCATION_SERVICE_URL = `${BAPP_CONFIG_URL}/geolocation`
|
||||
|
||||
/**
|
||||
* Default geolocation config.
|
||||
*/
|
||||
export const FALLBACK_GEOLOCATION_SERVICE_RESPONSE: Geolocation = {
|
||||
countryCode: undefined,
|
||||
regionCode: undefined,
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import * as aaDebug from '#/ageAssurance/debug'
|
||||
import {IS_DEV} from '#/env'
|
||||
import {type Geolocation} from '#/geolocation/types'
|
||||
|
||||
const localEnabled = false
|
||||
export const enabled = IS_DEV && (localEnabled || aaDebug.geolocation)
|
||||
export const geolocation: Geolocation = aaDebug.geolocation ?? {
|
||||
countryCode: 'AU',
|
||||
regionCode: undefined,
|
||||
}
|
||||
export const deviceGeolocation: Geolocation = aaDebug.deviceGeolocation ?? {
|
||||
countryCode: 'AU',
|
||||
regionCode: undefined,
|
||||
}
|
||||
|
||||
export async function resolve<T>(data: T) {
|
||||
await new Promise(y => setTimeout(y, 2000)) // simulate network
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import {useCallback, useEffect, useRef} from 'react'
|
||||
import * as Location from 'expo-location'
|
||||
import {createPermissionHook} from 'expo-modules-core'
|
||||
|
||||
import {isNative} from '#/platform/detection'
|
||||
import * as debug from '#/geolocation/debug'
|
||||
import {logger} from '#/geolocation/logger'
|
||||
import {type Geolocation} from '#/geolocation/types'
|
||||
import {normalizeDeviceLocation} from '#/geolocation/util'
|
||||
import {device} from '#/storage'
|
||||
|
||||
/**
|
||||
* Location.useForegroundPermissions on web just errors if the
|
||||
* navigator.permissions API is not available. We need to catch and ignore it,
|
||||
* since it's effectively denied.
|
||||
*
|
||||
* @see https://github.com/expo/expo/blob/72f1562ed9cce5ff6dfe04aa415b71632a3d4b87/packages/expo-location/src/Location.ts#L290-L293
|
||||
*/
|
||||
const useForegroundPermissions = createPermissionHook({
|
||||
getMethod: () =>
|
||||
Location.getForegroundPermissionsAsync().catch(error => {
|
||||
logger.debug(
|
||||
'useForegroundPermission: error getting location permissions',
|
||||
{safeMessage: error},
|
||||
)
|
||||
return {
|
||||
status: Location.PermissionStatus.DENIED,
|
||||
granted: false,
|
||||
canAskAgain: false,
|
||||
expires: 0,
|
||||
}
|
||||
}),
|
||||
requestMethod: () =>
|
||||
Location.requestForegroundPermissionsAsync().catch(error => {
|
||||
logger.debug(
|
||||
'useForegroundPermission: error requesting location permissions',
|
||||
{safeMessage: error},
|
||||
)
|
||||
return {
|
||||
status: Location.PermissionStatus.DENIED,
|
||||
granted: false,
|
||||
canAskAgain: false,
|
||||
expires: 0,
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
export async function getDeviceGeolocation(): Promise<Geolocation> {
|
||||
if (debug.enabled) return debug.resolve(debug.deviceGeolocation)
|
||||
|
||||
try {
|
||||
const geocode = await Location.getCurrentPositionAsync()
|
||||
const locations = await Location.reverseGeocodeAsync({
|
||||
latitude: geocode.coords.latitude,
|
||||
longitude: geocode.coords.longitude,
|
||||
})
|
||||
const location = locations.at(0)
|
||||
const normalized = location ? normalizeDeviceLocation(location) : undefined
|
||||
return {
|
||||
countryCode: normalized?.countryCode ?? undefined,
|
||||
regionCode: normalized?.regionCode ?? undefined,
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('getDeviceGeolocation: failed', {safeMessage: e})
|
||||
return {
|
||||
countryCode: undefined,
|
||||
regionCode: undefined,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function useRequestDeviceGeolocation(): () => Promise<
|
||||
| {
|
||||
granted: true
|
||||
location: Geolocation | undefined
|
||||
}
|
||||
| {
|
||||
granted: false
|
||||
}
|
||||
> {
|
||||
return useCallback(async () => {
|
||||
const status = await Location.requestForegroundPermissionsAsync()
|
||||
if (status.granted) {
|
||||
return {
|
||||
granted: true,
|
||||
location: await getDeviceGeolocation(),
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
granted: false,
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get and sync the device geolocation from the device GPS and store it
|
||||
* using device storage. If permissions are not granted, it will clear any cached
|
||||
* storage value.
|
||||
*/
|
||||
export function useSyncDeviceGeolocationOnStartup(
|
||||
sync: (location: Geolocation | undefined) => void,
|
||||
) {
|
||||
const synced = useRef(false)
|
||||
const [status] = useForegroundPermissions()
|
||||
useEffect(() => {
|
||||
if (!isNative) return
|
||||
|
||||
async function get() {
|
||||
// no need to set this more than once per session
|
||||
if (synced.current) return
|
||||
logger.debug('useSyncDeviceGeolocationOnStartup: checking perms')
|
||||
if (status?.granted) {
|
||||
const location = await getDeviceGeolocation()
|
||||
if (location) {
|
||||
logger.debug('useSyncDeviceGeolocationOnStartup: got location')
|
||||
sync(location)
|
||||
synced.current = true
|
||||
}
|
||||
} else {
|
||||
const hasCachedValue = device.get(['deviceGeolocation']) !== undefined
|
||||
/**
|
||||
* If we have a cached value, but user has revoked permissions,
|
||||
* quietly (will take effect lazily) clear this out.
|
||||
*/
|
||||
if (hasCachedValue) {
|
||||
logger.debug(
|
||||
'useSyncDeviceGeolocationOnStartup: clearing cached location, perms revoked',
|
||||
)
|
||||
device.set(['deviceGeolocation'], undefined)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get().catch(e => {
|
||||
logger.error(
|
||||
'useSyncDeviceGeolocationOnStartup: failed to get location',
|
||||
{
|
||||
safeMessage: e,
|
||||
},
|
||||
)
|
||||
})
|
||||
}, [status, sync])
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
createContext,
|
||||
type ReactNode,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
} from 'react'
|
||||
|
||||
import {useSyncDeviceGeolocationOnStartup} from '#/geolocation/device'
|
||||
import {useGeolocationServiceResponse} from '#/geolocation/service'
|
||||
import {type Geolocation} from '#/geolocation/types'
|
||||
import {mergeGeolocations} from '#/geolocation/util'
|
||||
import {device, useStorage} from '#/storage'
|
||||
|
||||
export {useRequestDeviceGeolocation} from '#/geolocation/device'
|
||||
export {resolve} from '#/geolocation/service'
|
||||
export * from '#/geolocation/types'
|
||||
|
||||
const GeolocationContext = createContext<Geolocation>({
|
||||
countryCode: undefined,
|
||||
regionCode: undefined,
|
||||
})
|
||||
|
||||
const DeviceGeolocationAPIContext = createContext<{
|
||||
setDeviceGeolocation(deviceGeolocation: Geolocation): void
|
||||
}>({
|
||||
setDeviceGeolocation: () => {},
|
||||
})
|
||||
|
||||
export function useGeolocation() {
|
||||
return useContext(GeolocationContext)
|
||||
}
|
||||
|
||||
export function useDeviceGeolocationApi() {
|
||||
return useContext(DeviceGeolocationAPIContext)
|
||||
}
|
||||
|
||||
export function Provider({children}: {children: ReactNode}) {
|
||||
const geolocationService = useGeolocationServiceResponse()
|
||||
const [deviceGeolocation, setDeviceGeolocation] = useStorage(device, [
|
||||
'deviceGeolocation',
|
||||
])
|
||||
const geolocation = useMemo(() => {
|
||||
return mergeGeolocations(deviceGeolocation, geolocationService)
|
||||
}, [deviceGeolocation, geolocationService])
|
||||
|
||||
useEffect(() => {
|
||||
/**
|
||||
* Save this for out-of-band-reads during future cold starts of the app.
|
||||
* Needs to be available for the data prefetching we do on boot.
|
||||
*/
|
||||
device.set(['mergedGeolocation'], geolocation)
|
||||
}, [geolocation])
|
||||
|
||||
useSyncDeviceGeolocationOnStartup(setDeviceGeolocation)
|
||||
|
||||
return (
|
||||
<GeolocationContext.Provider value={geolocation}>
|
||||
<DeviceGeolocationAPIContext.Provider
|
||||
value={useMemo(() => ({setDeviceGeolocation}), [setDeviceGeolocation])}>
|
||||
{children}
|
||||
</DeviceGeolocationAPIContext.Provider>
|
||||
</GeolocationContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import {useEffect, useState} from 'react'
|
||||
import EventEmitter from 'eventemitter3'
|
||||
|
||||
import {networkRetry} from '#/lib/async/retry'
|
||||
import {
|
||||
FALLBACK_GEOLOCATION_SERVICE_RESPONSE,
|
||||
GEOLOCATION_SERVICE_URL,
|
||||
} from '#/geolocation/const'
|
||||
import * as debug from '#/geolocation/debug'
|
||||
import {logger} from '#/geolocation/logger'
|
||||
import {type Geolocation} from '#/geolocation/types'
|
||||
import {device} from '#/storage'
|
||||
|
||||
const events = new EventEmitter()
|
||||
const EVENT = 'geolocation-service-response-updated'
|
||||
const emitGeolocationServiceResponseUpdate = (data: Geolocation) => {
|
||||
events.emit(EVENT, data)
|
||||
}
|
||||
const onGeolocationServiceResponseUpdate = (
|
||||
listener: (data: Geolocation) => void,
|
||||
) => {
|
||||
events.on(EVENT, listener)
|
||||
return () => {
|
||||
events.off(EVENT, listener)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchGeolocationServiceData(
|
||||
url: string,
|
||||
): Promise<Geolocation | undefined> {
|
||||
if (debug.enabled) return debug.resolve(debug.geolocation)
|
||||
const res = await fetch(url)
|
||||
if (!res.ok) {
|
||||
throw new Error(`fetchGeolocationServiceData failed ${res.status}`)
|
||||
}
|
||||
return res.json() as Promise<Geolocation>
|
||||
}
|
||||
|
||||
/**
|
||||
* Local promise used within this file only.
|
||||
*/
|
||||
let geolocationServicePromise: Promise<{success: boolean}> | undefined
|
||||
|
||||
/**
|
||||
* Begin the process of resolving geolocation config. This is called right away
|
||||
* at app start, and the promise is awaited later before proceeding with app
|
||||
* startup.
|
||||
*/
|
||||
export async function resolve() {
|
||||
if (geolocationServicePromise) {
|
||||
const cached = device.get(['geolocationServiceResponse'])
|
||||
if (cached) {
|
||||
logger.debug(`resolve(): using cache`)
|
||||
} else {
|
||||
logger.debug(`resolve(): no cache`)
|
||||
const {success} = await geolocationServicePromise
|
||||
if (success) {
|
||||
logger.debug(`resolve(): resolved`)
|
||||
} else {
|
||||
logger.info(`resolve(): failed`)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logger.debug(`resolve(): initiating`)
|
||||
|
||||
/**
|
||||
* THIS PROMISE SHOULD NEVER `reject()`! We want the app to proceed with
|
||||
* startup, even if geolocation resolution fails.
|
||||
*/
|
||||
geolocationServicePromise = new Promise(async resolve => {
|
||||
let success = false
|
||||
|
||||
function cacheResponseOrThrow(response: Geolocation | undefined) {
|
||||
if (response) {
|
||||
device.set(['geolocationServiceResponse'], response)
|
||||
emitGeolocationServiceResponseUpdate(response)
|
||||
} else {
|
||||
// endpoint should throw on all failures, this is insurance
|
||||
throw new Error(`fetchGeolocationServiceData returned no data`)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Try once, fail fast
|
||||
const config = await fetchGeolocationServiceData(
|
||||
GEOLOCATION_SERVICE_URL,
|
||||
)
|
||||
cacheResponseOrThrow(config)
|
||||
success = true
|
||||
} catch (e: any) {
|
||||
logger.debug(
|
||||
`resolve(): fetchGeolocationServiceData failed initial request`,
|
||||
{
|
||||
safeMessage: e.message,
|
||||
},
|
||||
)
|
||||
|
||||
// retry 3 times, but don't await, proceed with default
|
||||
networkRetry(3, () =>
|
||||
fetchGeolocationServiceData(GEOLOCATION_SERVICE_URL),
|
||||
)
|
||||
.then(config => {
|
||||
cacheResponseOrThrow(config)
|
||||
})
|
||||
.catch((e: any) => {
|
||||
// complete fail closed
|
||||
logger.debug(
|
||||
`resolve(): fetchGeolocationServiceData failed retries`,
|
||||
{
|
||||
safeMessage: e.message,
|
||||
},
|
||||
)
|
||||
})
|
||||
} finally {
|
||||
resolve({success})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function useGeolocationServiceResponse() {
|
||||
const [config, setConfig] = useState(() => {
|
||||
const initial =
|
||||
device.get(['geolocationServiceResponse']) ||
|
||||
FALLBACK_GEOLOCATION_SERVICE_RESPONSE
|
||||
return initial
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
return onGeolocationServiceResponseUpdate(config => {
|
||||
setConfig(config!)
|
||||
})
|
||||
}, [])
|
||||
|
||||
return config
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export type Geolocation = {
|
||||
countryCode: string | undefined
|
||||
regionCode: string | undefined
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import {type LocationGeocodedAddress} from 'expo-location'
|
||||
|
||||
import {logger} from '#/geolocation/logger'
|
||||
import {type Geolocation} from '#/geolocation/types'
|
||||
|
||||
/**
|
||||
* Maps full US region names to their short codes.
|
||||
*
|
||||
* Context: in some cases, like on Android, we get the full region name instead
|
||||
* of the short code. We may need to expand this in the future to other
|
||||
* countries, hence the prefix.
|
||||
*/
|
||||
export const USRegionNameToRegionCode: {
|
||||
[regionName: string]: string
|
||||
} = {
|
||||
Alabama: 'AL',
|
||||
Alaska: 'AK',
|
||||
Arizona: 'AZ',
|
||||
Arkansas: 'AR',
|
||||
California: 'CA',
|
||||
Colorado: 'CO',
|
||||
Connecticut: 'CT',
|
||||
Delaware: 'DE',
|
||||
Florida: 'FL',
|
||||
Georgia: 'GA',
|
||||
Hawaii: 'HI',
|
||||
Idaho: 'ID',
|
||||
Illinois: 'IL',
|
||||
Indiana: 'IN',
|
||||
Iowa: 'IA',
|
||||
Kansas: 'KS',
|
||||
Kentucky: 'KY',
|
||||
Louisiana: 'LA',
|
||||
Maine: 'ME',
|
||||
Maryland: 'MD',
|
||||
Massachusetts: 'MA',
|
||||
Michigan: 'MI',
|
||||
Minnesota: 'MN',
|
||||
Mississippi: 'MS',
|
||||
Missouri: 'MO',
|
||||
Montana: 'MT',
|
||||
Nebraska: 'NE',
|
||||
Nevada: 'NV',
|
||||
['New Hampshire']: 'NH',
|
||||
['New Jersey']: 'NJ',
|
||||
['New Mexico']: 'NM',
|
||||
['New York']: 'NY',
|
||||
['North Carolina']: 'NC',
|
||||
['North Dakota']: 'ND',
|
||||
Ohio: 'OH',
|
||||
Oklahoma: 'OK',
|
||||
Oregon: 'OR',
|
||||
Pennsylvania: 'PA',
|
||||
['Rhode Island']: 'RI',
|
||||
['South Carolina']: 'SC',
|
||||
['South Dakota']: 'SD',
|
||||
Tennessee: 'TN',
|
||||
Texas: 'TX',
|
||||
Utah: 'UT',
|
||||
Vermont: 'VT',
|
||||
Virginia: 'VA',
|
||||
Washington: 'WA',
|
||||
['West Virginia']: 'WV',
|
||||
Wisconsin: 'WI',
|
||||
Wyoming: 'WY',
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a `LocationGeocodedAddress` into a `Geolocation`.
|
||||
*
|
||||
* We don't want or care about the full location data, so we trim it down and
|
||||
* normalize certain fields, like region, into the format we need.
|
||||
*/
|
||||
export function normalizeDeviceLocation(
|
||||
location: LocationGeocodedAddress,
|
||||
): Geolocation {
|
||||
let {isoCountryCode, region} = location
|
||||
|
||||
if (region) {
|
||||
if (isoCountryCode === 'US') {
|
||||
region = USRegionNameToRegionCode[region] ?? region
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
countryCode: isoCountryCode ?? undefined,
|
||||
regionCode: region ?? undefined,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Combines precise location data with the geolocation config fetched from the
|
||||
* IP service, with preference to the precise data.
|
||||
*/
|
||||
export function mergeGeolocations(
|
||||
device?: Geolocation,
|
||||
geolocationService?: Geolocation,
|
||||
): Geolocation {
|
||||
let geolocation: Geolocation = {
|
||||
countryCode: geolocationService?.countryCode ?? undefined,
|
||||
regionCode: geolocationService?.regionCode ?? undefined,
|
||||
}
|
||||
// prefer GPS
|
||||
if (device?.countryCode) {
|
||||
geolocation = device
|
||||
}
|
||||
logger.debug('merged geolocation data', {
|
||||
device,
|
||||
service: geolocationService,
|
||||
merged: geolocation,
|
||||
})
|
||||
return geolocation
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import {describe, expect, it} from '@jest/globals'
|
||||
|
||||
import {parseLinkingUrl} from '../parseLinkingUrl'
|
||||
|
||||
describe('parseLinkingUrl', () => {
|
||||
it('should correctly parse bluesky:// URLs', () => {
|
||||
const url =
|
||||
'bluesky://intent/age-assurance?result=success&actorDid=did:example:123'
|
||||
const urlp = parseLinkingUrl(url)
|
||||
expect(urlp.protocol).toBe('bluesky:')
|
||||
expect(urlp.host).toBe('')
|
||||
expect(urlp.pathname).toBe('/intent/age-assurance')
|
||||
})
|
||||
|
||||
it('should correctly parse standard URLs', () => {
|
||||
const url =
|
||||
'https://bsky.app/intent/age-assurance?result=success&actorDid=did:example:123'
|
||||
const urlp = parseLinkingUrl(url)
|
||||
expect(urlp.protocol).toBe('https:')
|
||||
expect(urlp.host).toBe('bsky.app')
|
||||
expect(urlp.pathname).toBe('/intent/age-assurance')
|
||||
})
|
||||
})
|
||||
@@ -163,6 +163,7 @@ export async function resolveLink(
|
||||
const res = await agent.resolveHandle({
|
||||
handle: urip.host,
|
||||
})
|
||||
// @ts-expect-error TODO new-sdk-migration
|
||||
urip.host = res.data.did
|
||||
}
|
||||
const res = await agent.getPosts({
|
||||
|
||||
@@ -214,6 +214,7 @@ export const PUBLIC_APPVIEW_DID = 'did:web:api.bsky.app'
|
||||
export const PUBLIC_STAGING_APPVIEW_DID = 'did:web:api.staging.bsky.dev'
|
||||
|
||||
export const DEV_ENV_APPVIEW = `http://localhost:2584` // always the same
|
||||
export const DEV_ENV_APPVIEW_DID = `did:plc:dw4kbjf5mn7nhenabiqpkyh3` // always the same
|
||||
|
||||
// temp hack for e2e - esb
|
||||
export const BLUESKY_PROXY_HEADER = {
|
||||
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
import React from 'react'
|
||||
|
||||
import {deviceLocales} from '#/locale/deviceLocales'
|
||||
import {useGeolocationStatus} from '#/state/geolocation'
|
||||
import {useLanguagePrefs} from '#/state/preferences'
|
||||
import {useGeolocation} from '#/geolocation'
|
||||
|
||||
/**
|
||||
* From react-native-localize
|
||||
@@ -275,7 +275,7 @@ export const countryCodeToCurrency: Record<string, string> = {
|
||||
export function useFormatCurrency(
|
||||
options?: Parameters<typeof Intl.NumberFormat>[1],
|
||||
) {
|
||||
const {location: geolocation} = useGeolocationStatus()
|
||||
const geolocation = useGeolocation()
|
||||
const {appLanguage} = useLanguagePrefs()
|
||||
return React.useMemo(() => {
|
||||
const locale = deviceLocales.at(0)
|
||||
|
||||
@@ -36,7 +36,7 @@ export function useAccountSwitcher() {
|
||||
// So we change the URL ourselves. The navigator will pick it up on remount.
|
||||
history.pushState(null, '', '/')
|
||||
}
|
||||
await resumeSession(account)
|
||||
await resumeSession(account, true)
|
||||
logEvent('account:loggedIn', {logContext, withPassword: false})
|
||||
Toast.show(_(msg`Signed in as @${account.handle}`))
|
||||
} else {
|
||||
|
||||
@@ -9,6 +9,7 @@ export const ZENDESK_SUPPORT_URL =
|
||||
|
||||
export enum SupportCode {
|
||||
AA_DID = 'AA_DID',
|
||||
AA_BIRTHDATE = 'AA_BIRTHDATE',
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,14 +4,11 @@ import * as Linking from 'expo-linking'
|
||||
import * as WebBrowser from 'expo-web-browser'
|
||||
|
||||
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
|
||||
import {parseLinkingUrl} from '#/lib/parseLinkingUrl'
|
||||
import {logger} from '#/logger'
|
||||
import {isIOS, isNative} from '#/platform/detection'
|
||||
import {useSession} from '#/state/session'
|
||||
import {useCloseAllActiveElements} from '#/state/util'
|
||||
import {
|
||||
parseAgeAssuranceRedirectDialogState,
|
||||
useAgeAssuranceRedirectDialogControl,
|
||||
} from '#/components/ageAssurance/AgeAssuranceRedirectDialog'
|
||||
import {useIntentDialogs} from '#/components/intents/IntentDialogs'
|
||||
import {Referrer} from '../../../modules/expo-bluesky-swiss-army'
|
||||
import {useApplyPullRequestOTAUpdate} from './useOTAUpdates'
|
||||
@@ -27,8 +24,6 @@ export function useIntentHandler() {
|
||||
const incomingUrl = Linking.useLinkingURL()
|
||||
const composeIntent = useComposeIntent()
|
||||
const verifyEmailIntent = useVerifyEmailIntent()
|
||||
const ageAssuranceRedirectDialogControl =
|
||||
useAgeAssuranceRedirectDialogControl()
|
||||
const {currentAccount} = useSession()
|
||||
const {tryApplyUpdate} = useApplyPullRequestOTAUpdate()
|
||||
|
||||
@@ -47,17 +42,8 @@ export function useIntentHandler() {
|
||||
hostname: referrerInfo?.hostname,
|
||||
})
|
||||
}
|
||||
|
||||
// We want to be able to support bluesky:// deeplinks. It's unnatural for someone to use a deeplink with three
|
||||
// slashes, like bluesky:///intent/follow. However, supporting just two slashes causes us to have to take care
|
||||
// of two cases when parsing the url. If we ensure there is a third slash, we can always ensure the first
|
||||
// path parameter is in pathname rather than in hostname.
|
||||
if (url.startsWith('bluesky://') && !url.startsWith('bluesky:///')) {
|
||||
url = url.replace('bluesky://', 'bluesky:///')
|
||||
}
|
||||
|
||||
const urlp = new URL(url)
|
||||
const [__, intent, intentType] = urlp.pathname.split('/')
|
||||
const urlp = parseLinkingUrl(url)
|
||||
const [, intent, intentType] = urlp.pathname.split('/')
|
||||
|
||||
// On native, our links look like bluesky://intent/SomeIntent, so we have to check the hostname for the
|
||||
// intent check. On web, we have to check the first part of the path since we have an actual hostname
|
||||
@@ -82,23 +68,7 @@ export function useIntentHandler() {
|
||||
return
|
||||
}
|
||||
case 'age-assurance': {
|
||||
const state = parseAgeAssuranceRedirectDialogState({
|
||||
result: params.get('result') ?? undefined,
|
||||
actorDid: params.get('actorDid') ?? undefined,
|
||||
})
|
||||
|
||||
/*
|
||||
* If we don't have an account or the account doesn't match, do
|
||||
* nothing. By the time the user switches to their other account, AA
|
||||
* state should be ready for them.
|
||||
*/
|
||||
if (
|
||||
state &&
|
||||
currentAccount &&
|
||||
state.actorDid === currentAccount.did
|
||||
) {
|
||||
ageAssuranceRedirectDialogControl.open(state)
|
||||
}
|
||||
// Handled in `#/ageAssurance/components/RedirectOverlay.tsx`
|
||||
return
|
||||
}
|
||||
case 'apply-ota': {
|
||||
@@ -127,7 +97,6 @@ export function useIntentHandler() {
|
||||
incomingUrl,
|
||||
composeIntent,
|
||||
verifyEmailIntent,
|
||||
ageAssuranceRedirectDialogControl,
|
||||
currentAccount,
|
||||
tryApplyUpdate,
|
||||
])
|
||||
|
||||
@@ -1,316 +1,56 @@
|
||||
import {type ImagePickerAsset} from 'expo-image-picker'
|
||||
import {
|
||||
ALL_FORMATS,
|
||||
type AudioCodec,
|
||||
BlobSource,
|
||||
BufferTarget,
|
||||
canEncodeAudio,
|
||||
canEncodeVideo,
|
||||
Conversion,
|
||||
Input,
|
||||
Mp4OutputFormat,
|
||||
Output,
|
||||
type VideoCodec,
|
||||
WebMOutputFormat,
|
||||
} from 'mediabunny'
|
||||
|
||||
import {VIDEO_MAX_SIZE} from '#/lib/constants'
|
||||
import {VideoTooLargeError} from '#/lib/media/video/errors'
|
||||
import {logger} from '#/logger'
|
||||
import {hasWebCodecs} from '#/view/com/composer/videos/metadata'
|
||||
import {type CompressedVideo} from './types'
|
||||
|
||||
const TARGET_BITRATE = 3_000_000 // 3mbps, matches native
|
||||
const MAX_DIMENSION = 1920 // matches native
|
||||
const MIN_SIZE_FOR_COMPRESSION = 25 * 1000 * 1000 // 25mb, matches native
|
||||
|
||||
// Codecs to try in order of preference
|
||||
// avc (H.264) is most compatible, vp9/vp8 are fallbacks for WebM
|
||||
const VIDEO_CODECS: VideoCodec[] = ['avc', 'hevc', 'vp9', 'vp8']
|
||||
|
||||
// doesn't actually compress, converts to ArrayBuffer
|
||||
export async function compressVideo(
|
||||
asset: ImagePickerAsset,
|
||||
opts?: {
|
||||
_opts?: {
|
||||
signal?: AbortSignal
|
||||
onProgress?: (progress: number) => void
|
||||
},
|
||||
): Promise<CompressedVideo> {
|
||||
const {onProgress, signal} = opts || {}
|
||||
const {mimeType, base64} = parseDataUrl(asset.uri)
|
||||
const blob = base64ToBlob(base64, mimeType)
|
||||
const uri = URL.createObjectURL(blob)
|
||||
|
||||
logger.debug('compress: starting', {
|
||||
uri: asset.uri.slice(0, 50),
|
||||
hasWebCodecs: hasWebCodecs(),
|
||||
})
|
||||
|
||||
const response = await fetch(asset.uri)
|
||||
const blob = await response.blob()
|
||||
|
||||
const isGif = blob.type === 'image/gif'
|
||||
|
||||
logger.debug('compress: fetched blob', {
|
||||
size: blob.size,
|
||||
mimeType: blob.type,
|
||||
isGif,
|
||||
minSizeForCompression: MIN_SIZE_FOR_COMPRESSION,
|
||||
})
|
||||
|
||||
// Try MediaBunny compression if WebCodecs is available and file is large enough
|
||||
// Skip GIFs - MediaBunny doesn't support them
|
||||
if (hasWebCodecs() && blob.size >= MIN_SIZE_FOR_COMPRESSION && !isGif) {
|
||||
try {
|
||||
return await doCompression(blob, asset.uri, {onProgress, signal})
|
||||
} catch (e) {
|
||||
logger.warn('compress: MediaBunny compression failed, using original', {
|
||||
safeMessage: e,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
logger.debug('compress: skipping compression', {
|
||||
hasWebCodecs: hasWebCodecs(),
|
||||
blobSize: blob.size,
|
||||
minSize: MIN_SIZE_FOR_COMPRESSION,
|
||||
})
|
||||
}
|
||||
|
||||
// No compression path - just return the blob as-is
|
||||
if (blob.size > VIDEO_MAX_SIZE) {
|
||||
throw new VideoTooLargeError()
|
||||
}
|
||||
|
||||
return {
|
||||
uri: asset.uri,
|
||||
size: blob.size,
|
||||
uri,
|
||||
bytes: await blob.arrayBuffer(),
|
||||
mimeType: blob.type || 'video/mp4',
|
||||
}
|
||||
}
|
||||
|
||||
async function findEncodableVideoCodec(
|
||||
width: number,
|
||||
height: number,
|
||||
): Promise<{codec: VideoCodec; useWebM: boolean} | null> {
|
||||
for (const codec of VIDEO_CODECS) {
|
||||
const canEncode = await canEncodeVideo(codec, {
|
||||
width,
|
||||
height,
|
||||
bitrate: TARGET_BITRATE,
|
||||
})
|
||||
logger.debug('compress: checking video codec', {
|
||||
codec,
|
||||
canEncode,
|
||||
width,
|
||||
height,
|
||||
})
|
||||
if (canEncode) {
|
||||
// vp8/vp9 need WebM container, others use MP4
|
||||
const useWebM = codec === 'vp8' || codec === 'vp9'
|
||||
return {codec, useWebM}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Audio codecs to try - aac for MP4, opus for WebM
|
||||
const AUDIO_CODECS_MP4: AudioCodec[] = ['aac']
|
||||
const AUDIO_CODECS_WEBM: AudioCodec[] = ['opus', 'vorbis']
|
||||
|
||||
async function findEncodableAudioCodec(
|
||||
audioTrack: Awaited<ReturnType<Input['getPrimaryAudioTrack']>>,
|
||||
useWebM: boolean,
|
||||
): Promise<{codec: AudioCodec} | null> {
|
||||
if (!audioTrack) {
|
||||
return null
|
||||
}
|
||||
|
||||
// First check if we can decode the source audio
|
||||
const canDecodeSource = await audioTrack.canDecode()
|
||||
logger.debug('compress: checking audio source', {
|
||||
sourceCodec: audioTrack.codec,
|
||||
canDecode: canDecodeSource,
|
||||
channels: audioTrack.numberOfChannels,
|
||||
sampleRate: audioTrack.sampleRate,
|
||||
})
|
||||
|
||||
if (!canDecodeSource) {
|
||||
return null
|
||||
}
|
||||
|
||||
const codecsToTry = useWebM ? AUDIO_CODECS_WEBM : AUDIO_CODECS_MP4
|
||||
|
||||
for (const codec of codecsToTry) {
|
||||
const canEncode = await canEncodeAudio(codec, {
|
||||
numberOfChannels: audioTrack.numberOfChannels,
|
||||
sampleRate: audioTrack.sampleRate,
|
||||
})
|
||||
logger.debug('compress: checking audio encode codec', {
|
||||
codec,
|
||||
canEncode,
|
||||
})
|
||||
if (canEncode) {
|
||||
return {codec}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async function doCompression(
|
||||
blob: Blob,
|
||||
originalUri: string,
|
||||
opts: {
|
||||
onProgress?: (progress: number) => void
|
||||
signal?: AbortSignal
|
||||
},
|
||||
): Promise<CompressedVideo> {
|
||||
const {onProgress, signal} = opts
|
||||
|
||||
const input = new Input({
|
||||
source: new BlobSource(blob),
|
||||
formats: ALL_FORMATS,
|
||||
})
|
||||
|
||||
// Get video track to determine dimensions for codec check
|
||||
const videoTrack = await input.getPrimaryVideoTrack()
|
||||
if (!videoTrack) {
|
||||
input.dispose()
|
||||
throw new Error('No video track found')
|
||||
}
|
||||
|
||||
// Get audio track to check if we can encode it
|
||||
const audioTrack = await input.getPrimaryAudioTrack()
|
||||
|
||||
const {width, height} = calculateDimensions(
|
||||
videoTrack.displayWidth,
|
||||
videoTrack.displayHeight,
|
||||
MAX_DIMENSION,
|
||||
)
|
||||
|
||||
logger.debug('compress: video dimensions', {
|
||||
original: {
|
||||
width: videoTrack.displayWidth,
|
||||
height: videoTrack.displayHeight,
|
||||
},
|
||||
target: {width, height},
|
||||
audioCodec: audioTrack?.codec,
|
||||
})
|
||||
|
||||
// Find a video codec we can encode with
|
||||
const codecInfo = await findEncodableVideoCodec(width, height)
|
||||
if (!codecInfo) {
|
||||
input.dispose()
|
||||
throw new Error('No supported video codec available')
|
||||
}
|
||||
|
||||
// Check if we can encode the audio
|
||||
const audioCodecInfo = await findEncodableAudioCodec(
|
||||
audioTrack,
|
||||
codecInfo.useWebM,
|
||||
)
|
||||
|
||||
logger.debug('compress: using codecs', {
|
||||
video: codecInfo.codec,
|
||||
audio: audioCodecInfo?.codec ?? 'none',
|
||||
useWebM: codecInfo.useWebM,
|
||||
})
|
||||
|
||||
const target = new BufferTarget()
|
||||
const output = new Output({
|
||||
format: codecInfo.useWebM ? new WebMOutputFormat() : new Mp4OutputFormat(),
|
||||
target,
|
||||
})
|
||||
|
||||
// If we have audio but can't encode it, bail out and use the original
|
||||
if (audioTrack && !audioCodecInfo) {
|
||||
input.dispose()
|
||||
throw new Error(
|
||||
`Cannot encode audio codec: ${audioTrack.codec ?? 'unknown'}`,
|
||||
)
|
||||
}
|
||||
|
||||
const conversion = await Conversion.init({
|
||||
input,
|
||||
output,
|
||||
video: {
|
||||
codec: codecInfo.codec,
|
||||
bitrate: TARGET_BITRATE,
|
||||
width,
|
||||
height,
|
||||
fit: 'contain',
|
||||
},
|
||||
audio: audioCodecInfo ? {codec: audioCodecInfo.codec} : undefined,
|
||||
})
|
||||
|
||||
if (onProgress) {
|
||||
conversion.onProgress = onProgress
|
||||
}
|
||||
|
||||
if (signal) {
|
||||
signal.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
logger.debug('compress: cancelled')
|
||||
conversion.cancel()
|
||||
},
|
||||
{once: true},
|
||||
)
|
||||
}
|
||||
|
||||
logger.debug('compress: starting conversion')
|
||||
const startTime = performance.now()
|
||||
|
||||
try {
|
||||
await conversion.execute()
|
||||
} finally {
|
||||
input.dispose()
|
||||
}
|
||||
|
||||
const elapsed = performance.now() - startTime
|
||||
const bytes = target.buffer
|
||||
|
||||
if (!bytes) {
|
||||
throw new Error('MediaBunny compression produced no output')
|
||||
}
|
||||
|
||||
const mimeType = codecInfo.useWebM ? 'video/webm' : 'video/mp4'
|
||||
|
||||
const savedBytes = blob.size - bytes.byteLength
|
||||
const savedPercent = ((savedBytes / blob.size) * 100).toFixed(1)
|
||||
|
||||
logger.debug('compress: completed', {
|
||||
from: blob.type,
|
||||
to: mimeType,
|
||||
originalSize: blob.size,
|
||||
compressedSize: bytes.byteLength,
|
||||
savedBytes,
|
||||
savedPercent: `${savedPercent}%`,
|
||||
elapsedMs: Math.round(elapsed),
|
||||
})
|
||||
|
||||
if (bytes.byteLength > VIDEO_MAX_SIZE) {
|
||||
throw new VideoTooLargeError()
|
||||
}
|
||||
|
||||
return {
|
||||
uri: originalUri,
|
||||
size: bytes.byteLength,
|
||||
bytes,
|
||||
mimeType,
|
||||
}
|
||||
}
|
||||
|
||||
function calculateDimensions(
|
||||
width: number,
|
||||
height: number,
|
||||
maxDimension: number,
|
||||
): {width: number; height: number} {
|
||||
const maxSide = Math.max(width, height)
|
||||
if (maxSide <= maxDimension) {
|
||||
return {width, height}
|
||||
function parseDataUrl(dataUrl: string) {
|
||||
const [mimeType, base64] = dataUrl.slice('data:'.length).split(';base64,')
|
||||
if (!mimeType || !base64) {
|
||||
throw new Error('Invalid data URL')
|
||||
}
|
||||
return {mimeType, base64}
|
||||
}
|
||||
|
||||
function base64ToBlob(base64: string, mimeType: string) {
|
||||
const byteCharacters = atob(base64)
|
||||
const byteArrays = []
|
||||
|
||||
for (let offset = 0; offset < byteCharacters.length; offset += 512) {
|
||||
const slice = byteCharacters.slice(offset, offset + 512)
|
||||
const byteNumbers = new Array(slice.length)
|
||||
|
||||
for (let i = 0; i < slice.length; i++) {
|
||||
byteNumbers[i] = slice.charCodeAt(i)
|
||||
}
|
||||
|
||||
const byteArray = new Uint8Array(byteNumbers)
|
||||
byteArrays.push(byteArray)
|
||||
}
|
||||
|
||||
const scale = maxDimension / maxSide
|
||||
return {
|
||||
width: Math.round(width * scale),
|
||||
height: Math.round(height * scale),
|
||||
}
|
||||
return new Blob(byteArrays, {type: mimeType})
|
||||
}
|
||||
|
||||
@@ -9,9 +9,9 @@ import {PUBLIC_APPVIEW_DID, PUBLIC_STAGING_APPVIEW_DID} from '#/lib/constants'
|
||||
import {logger as notyLogger} from '#/lib/notifications/util'
|
||||
import {isNetworkError} from '#/lib/strings/errors'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {useAgeAssuranceContext} from '#/state/ageAssurance'
|
||||
import {type SessionAccount, useAgent, useSession} from '#/state/session'
|
||||
import BackgroundNotificationHandler from '#/../modules/expo-background-notification-handler'
|
||||
import {useAgeAssurance} from '#/ageAssurance'
|
||||
import {IS_DEV} from '#/env'
|
||||
|
||||
/**
|
||||
@@ -125,7 +125,7 @@ async function getPushToken() {
|
||||
* @see https://github.com/bluesky-social/social-app/pull/4467
|
||||
*/
|
||||
export function useGetAndRegisterPushToken() {
|
||||
const {isAgeRestricted} = useAgeAssuranceContext()
|
||||
const aa = useAgeAssurance()
|
||||
const registerPushToken = useRegisterPushToken()
|
||||
return useCallback(
|
||||
async ({
|
||||
@@ -152,13 +152,14 @@ export function useGetAndRegisterPushToken() {
|
||||
*/
|
||||
registerPushToken({
|
||||
token,
|
||||
isAgeRestricted: isAgeRestrictedOverride ?? isAgeRestricted,
|
||||
isAgeRestricted:
|
||||
isAgeRestrictedOverride ?? aa.state.access !== aa.Access.Full,
|
||||
})
|
||||
}
|
||||
|
||||
return token
|
||||
},
|
||||
[registerPushToken, isAgeRestricted],
|
||||
[registerPushToken, aa],
|
||||
)
|
||||
}
|
||||
|
||||
@@ -173,15 +174,14 @@ export function useNotificationsRegistration() {
|
||||
const {currentAccount} = useSession()
|
||||
const registerPushToken = useRegisterPushToken()
|
||||
const getAndRegisterPushToken = useGetAndRegisterPushToken()
|
||||
const {isReady: isAgeRestrictionReady, isAgeRestricted} =
|
||||
useAgeAssuranceContext()
|
||||
const aa = useAgeAssurance()
|
||||
|
||||
useEffect(() => {
|
||||
/**
|
||||
* We want this to init right away _after_ we have a logged in user, and
|
||||
* _after_ we've loaded their age assurance state.
|
||||
*/
|
||||
if (!currentAccount || !isAgeRestrictionReady) return
|
||||
if (!currentAccount) return
|
||||
|
||||
notyLogger.debug(`useNotificationsRegistration`)
|
||||
|
||||
@@ -206,20 +206,17 @@ export function useNotificationsRegistration() {
|
||||
* @see https://docs.expo.dev/versions/latest/sdk/notifications/#addpushtokenlistenerlistener
|
||||
*/
|
||||
const subscription = Notifications.addPushTokenListener(async token => {
|
||||
registerPushToken({token, isAgeRestricted: isAgeRestricted})
|
||||
registerPushToken({
|
||||
token,
|
||||
isAgeRestricted: aa.state.access !== aa.Access.Full,
|
||||
})
|
||||
notyLogger.debug(`addPushTokenListener callback`, {token})
|
||||
})
|
||||
|
||||
return () => {
|
||||
subscription.remove()
|
||||
}
|
||||
}, [
|
||||
currentAccount,
|
||||
getAndRegisterPushToken,
|
||||
registerPushToken,
|
||||
isAgeRestrictionReady,
|
||||
isAgeRestricted,
|
||||
])
|
||||
}, [currentAccount, getAndRegisterPushToken, registerPushToken, aa])
|
||||
}
|
||||
|
||||
export function useRequestNotificationsPermission() {
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
export function parseLinkingUrl(url: string): URL {
|
||||
/*
|
||||
* Hack: add a third slash to bluesky:// urls so that `URL.host` is empty and
|
||||
* `URL.pathname` has the full path.
|
||||
*/
|
||||
if (url.startsWith('bluesky://') && !url.startsWith('bluesky:///')) {
|
||||
url = url.replace('bluesky://', 'bluesky:///')
|
||||
}
|
||||
return new URL(url)
|
||||
}
|
||||
@@ -52,3 +52,15 @@ export function isErrorMaybeAppPasswordPermissions(e: unknown) {
|
||||
const str = String(e)
|
||||
return str.includes('Bad token scope') || str.includes('Bad token method')
|
||||
}
|
||||
|
||||
/**
|
||||
* Intended to capture "User cancelled" or "Crop cancelled" errors
|
||||
* that we often get from expo modules such expo-image-crop-tool
|
||||
*
|
||||
* The exact name has changed in the past so let's just see if the string
|
||||
* contains "cancel"
|
||||
*/
|
||||
export function isCancelledError(e: unknown) {
|
||||
const str = String(e).toLowerCase()
|
||||
return str.includes('cancel')
|
||||
}
|
||||
|
||||
+11
-1
@@ -1,12 +1,22 @@
|
||||
import {type I18n} from '@lingui/core'
|
||||
import {msg} from '@lingui/macro'
|
||||
|
||||
export function niceDate(
|
||||
i18n: I18n,
|
||||
date: number | string | Date,
|
||||
dateStyle: 'short' | 'medium' | 'long' | 'full' = 'long',
|
||||
dateStyle: 'short' | 'medium' | 'long' | 'full' | 'dot separated' = 'long',
|
||||
) {
|
||||
const d = new Date(date)
|
||||
|
||||
if (dateStyle === 'dot separated') {
|
||||
return i18n._(
|
||||
msg({
|
||||
context: 'date and time formatted like this: [time] · [date]',
|
||||
message: `${i18n.date(d, {timeStyle: 'short'})} · ${i18n.date(d, {day: 'numeric', month: 'numeric', year: '2-digit'})}`,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
return i18n.date(d, {
|
||||
dateStyle,
|
||||
timeStyle: 'short',
|
||||
|
||||
@@ -41,7 +41,8 @@ export function makeRecordUri(
|
||||
collection: string,
|
||||
rkey: string,
|
||||
) {
|
||||
const urip = new AtUri('at://host/')
|
||||
const urip = new AtUri('at://placeholder.placeholder/')
|
||||
// @ts-expect-error TODO new-sdk-migration
|
||||
urip.host = didOrName
|
||||
urip.collection = collection
|
||||
urip.rkey = rkey
|
||||
|
||||
+229
-156
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,7 @@ export type MetricEvents = {
|
||||
| 'SignupQueued'
|
||||
| 'Deactivated'
|
||||
| 'Takendown'
|
||||
| 'AgeAssuranceNoAccessScreen'
|
||||
scope: 'current' | 'every'
|
||||
}
|
||||
'notifications:openApp': {
|
||||
|
||||
@@ -45,7 +45,7 @@ export const ChooseAccountForm = ({
|
||||
}
|
||||
try {
|
||||
setPendingDid(account.did)
|
||||
await resumeSession(account)
|
||||
await resumeSession(account, true)
|
||||
logEvent('account:loggedIn', {
|
||||
logContext: 'ChooseAccountForm',
|
||||
withPassword: false,
|
||||
|
||||
+104
-133
@@ -12,7 +12,7 @@ import {
|
||||
} from '#/lib/routes/types'
|
||||
import {logger} from '#/logger'
|
||||
import {isIOS} from '#/platform/detection'
|
||||
import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance'
|
||||
import {useIsBirthdateUpdateAllowed} from '#/state/birthdate'
|
||||
import {
|
||||
useMyLabelersQuery,
|
||||
usePreferencesQuery,
|
||||
@@ -24,9 +24,8 @@ import {useSetMinimalShellMode} from '#/state/shell'
|
||||
import {atoms as a, useBreakpoints, useTheme, type ViewStyleProp} from '#/alf'
|
||||
import {Admonition} from '#/components/Admonition'
|
||||
import {AgeAssuranceAdmonition} from '#/components/ageAssurance/AgeAssuranceAdmonition'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings'
|
||||
import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy'
|
||||
import {Button} from '#/components/Button'
|
||||
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
|
||||
import {Divider} from '#/components/Divider'
|
||||
import * as Toggle from '#/components/forms/Toggle'
|
||||
@@ -45,6 +44,7 @@ import {ListMaybePlaceholder} from '#/components/Lists'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {GlobalLabelPreference} from '#/components/moderation/LabelPreference'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {useAgeAssurance} from '#/ageAssurance'
|
||||
|
||||
function ErrorState({error}: {error: string}) {
|
||||
const t = useTheme()
|
||||
@@ -86,9 +86,8 @@ export function ModerationScreen(
|
||||
error: preferencesError,
|
||||
data: preferences,
|
||||
} = usePreferencesQuery()
|
||||
const {isReady: isAgeInfoReady} = useAgeAssurance()
|
||||
|
||||
const isLoading = isPreferencesLoading || !isAgeInfoReady
|
||||
const isLoading = isPreferencesLoading
|
||||
const error = preferencesError
|
||||
|
||||
return (
|
||||
@@ -162,13 +161,14 @@ export function ModerationScreenInner({
|
||||
const setMinimalShellMode = useSetMinimalShellMode()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const {mutedWordsDialogControl} = useGlobalDialogsControlContext()
|
||||
const birthdateDialogControl = Dialog.useDialogControl()
|
||||
const {
|
||||
isLoading: isLabelersLoading,
|
||||
data: labelers,
|
||||
error: labelersError,
|
||||
} = useMyLabelersQuery()
|
||||
const {declaredAge, isDeclaredUnderage, isAgeRestricted} = useAgeAssurance()
|
||||
const aa = useAgeAssurance()
|
||||
const isBirthdateUpdateAllowed = useIsBirthdateUpdateAllowed()
|
||||
const aaCopy = useAgeAssuranceCopy()
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
@@ -178,10 +178,17 @@ export function ModerationScreenInner({
|
||||
|
||||
const {mutateAsync: setAdultContentPref, variables: optimisticAdultContent} =
|
||||
usePreferencesSetAdultContentMutation()
|
||||
const adultContentEnabled = !!(
|
||||
let adultContentEnabled = !!(
|
||||
(optimisticAdultContent && optimisticAdultContent.enabled) ||
|
||||
(!optimisticAdultContent && preferences.moderationPrefs.adultContentEnabled)
|
||||
)
|
||||
const adultContentUIDisabledOnIOS = isIOS && !adultContentEnabled
|
||||
let adultContentUIDisabled = adultContentUIDisabledOnIOS
|
||||
|
||||
if (aa.flags.adultContentDisabled) {
|
||||
adultContentEnabled = false
|
||||
adultContentUIDisabled = true
|
||||
}
|
||||
|
||||
const onToggleAdultContentEnabled = useCallback(
|
||||
async (selected: boolean) => {
|
||||
@@ -198,11 +205,9 @@ export function ModerationScreenInner({
|
||||
[setAdultContentPref],
|
||||
)
|
||||
|
||||
const disabledOnIOS = isIOS && !adultContentEnabled
|
||||
|
||||
return (
|
||||
<View style={[a.pt_2xl, a.px_lg, gtMobile && a.px_2xl]}>
|
||||
{isDeclaredUnderage && (
|
||||
{aa.flags.adultContentDisabled && isBirthdateUpdateAllowed && (
|
||||
<View style={[a.pb_2xl]}>
|
||||
<Admonition type="tip" style={[a.pb_md]}>
|
||||
<Trans>
|
||||
@@ -328,134 +333,100 @@ export function ModerationScreenInner({
|
||||
</Link>
|
||||
</View>
|
||||
|
||||
{(!isDeclaredUnderage || declaredAge === undefined) && (
|
||||
<Text
|
||||
<Text
|
||||
style={[
|
||||
a.pt_2xl,
|
||||
a.pb_md,
|
||||
a.text_md,
|
||||
a.font_semi_bold,
|
||||
t.atoms.text_contrast_high,
|
||||
]}>
|
||||
<Trans>Content filters</Trans>
|
||||
</Text>
|
||||
|
||||
<AgeAssuranceAdmonition style={[a.pb_md]}>
|
||||
{aaCopy.notice}
|
||||
</AgeAssuranceAdmonition>
|
||||
|
||||
<View style={[a.gap_md]}>
|
||||
<View
|
||||
style={[
|
||||
a.pt_2xl,
|
||||
a.pb_md,
|
||||
a.text_md,
|
||||
a.font_semi_bold,
|
||||
t.atoms.text_contrast_high,
|
||||
a.w_full,
|
||||
a.rounded_md,
|
||||
a.overflow_hidden,
|
||||
t.atoms.bg_contrast_25,
|
||||
]}>
|
||||
<Trans>Content filters</Trans>
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{declaredAge === undefined ? (
|
||||
<>
|
||||
<Button
|
||||
label={_(msg`Confirm your birthdate`)}
|
||||
size="small"
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
onPress={() => {
|
||||
birthdateDialogControl.open()
|
||||
}}
|
||||
style={[a.justify_between, a.rounded_md, a.px_lg, a.py_lg]}>
|
||||
<ButtonText>
|
||||
<Trans>Confirm your age:</Trans>
|
||||
</ButtonText>
|
||||
<ButtonText>
|
||||
<Trans>Set birthdate</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
|
||||
<BirthDateSettingsDialog control={birthdateDialogControl} />
|
||||
</>
|
||||
) : !isDeclaredUnderage ? (
|
||||
<>
|
||||
<AgeAssuranceAdmonition style={[a.pb_md]}>
|
||||
<Trans>
|
||||
You must complete age assurance in order to access the settings
|
||||
below.
|
||||
</Trans>
|
||||
</AgeAssuranceAdmonition>
|
||||
|
||||
<View style={[a.gap_md]}>
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
a.rounded_md,
|
||||
a.overflow_hidden,
|
||||
t.atoms.bg_contrast_25,
|
||||
]}>
|
||||
{!isDeclaredUnderage && (
|
||||
<>
|
||||
<View
|
||||
style={[
|
||||
a.py_lg,
|
||||
a.px_lg,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.justify_between,
|
||||
disabledOnIOS && {opacity: 0.5},
|
||||
]}>
|
||||
<Text
|
||||
style={[a.font_semi_bold, t.atoms.text_contrast_high]}>
|
||||
<Trans>Enable adult content</Trans>
|
||||
{aa.state.access === aa.Access.Full && (
|
||||
<>
|
||||
<View
|
||||
style={[
|
||||
a.py_lg,
|
||||
a.px_lg,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.justify_between,
|
||||
adultContentUIDisabled && {opacity: 0.5},
|
||||
]}>
|
||||
<Text style={[a.font_semi_bold, t.atoms.text_contrast_high]}>
|
||||
<Trans>Enable adult content</Trans>
|
||||
</Text>
|
||||
<Toggle.Item
|
||||
label={_(msg`Toggle to enable or disable adult content`)}
|
||||
disabled={adultContentUIDisabled}
|
||||
name="adultContent"
|
||||
value={adultContentEnabled}
|
||||
onChange={onToggleAdultContentEnabled}>
|
||||
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
|
||||
<Text style={[t.atoms.text_contrast_medium]}>
|
||||
{adultContentEnabled ? (
|
||||
<Trans>Enabled</Trans>
|
||||
) : (
|
||||
<Trans>Disabled</Trans>
|
||||
)}
|
||||
</Text>
|
||||
<Toggle.Item
|
||||
label={_(msg`Toggle to enable or disable adult content`)}
|
||||
disabled={disabledOnIOS || isAgeRestricted}
|
||||
name="adultContent"
|
||||
value={adultContentEnabled}
|
||||
onChange={onToggleAdultContentEnabled}>
|
||||
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
|
||||
<Text style={[t.atoms.text_contrast_medium]}>
|
||||
{adultContentEnabled ? (
|
||||
<Trans>Enabled</Trans>
|
||||
) : (
|
||||
<Trans>Disabled</Trans>
|
||||
)}
|
||||
</Text>
|
||||
<Toggle.Switch />
|
||||
</View>
|
||||
</Toggle.Item>
|
||||
<Toggle.Switch />
|
||||
</View>
|
||||
{disabledOnIOS && (
|
||||
<View style={[a.pb_lg, a.px_lg]}>
|
||||
<Text>
|
||||
<Trans>
|
||||
Adult content can only be enabled via the Web at{' '}
|
||||
<InlineLinkText
|
||||
label={_(msg`The Bluesky web application`)}
|
||||
to=""
|
||||
onPress={evt => {
|
||||
evt.preventDefault()
|
||||
Linking.openURL('https://bsky.app/')
|
||||
return false
|
||||
}}>
|
||||
bsky.app
|
||||
</InlineLinkText>
|
||||
.
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</Toggle.Item>
|
||||
</View>
|
||||
{adultContentUIDisabledOnIOS && (
|
||||
<View style={[a.pb_lg, a.px_lg]}>
|
||||
<Text>
|
||||
<Trans>
|
||||
Adult content can only be enabled via the Web at{' '}
|
||||
<InlineLinkText
|
||||
label={_(msg`The Bluesky web application`)}
|
||||
to=""
|
||||
onPress={evt => {
|
||||
evt.preventDefault()
|
||||
Linking.openURL('https://bsky.app/')
|
||||
return false
|
||||
}}>
|
||||
bsky.app
|
||||
</InlineLinkText>
|
||||
.
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{adultContentEnabled && (
|
||||
<>
|
||||
<Divider />
|
||||
<GlobalLabelPreference labelDefinition={LABELS.porn} />
|
||||
<Divider />
|
||||
<GlobalLabelPreference labelDefinition={LABELS.sexual} />
|
||||
<Divider />
|
||||
<GlobalLabelPreference
|
||||
labelDefinition={LABELS['graphic-media']}
|
||||
/>
|
||||
<Divider />
|
||||
<GlobalLabelPreference
|
||||
disabled={isDeclaredUnderage || isAgeRestricted}
|
||||
labelDefinition={LABELS.nudity}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{adultContentEnabled && (
|
||||
<>
|
||||
<Divider />
|
||||
<GlobalLabelPreference labelDefinition={LABELS.porn} />
|
||||
<Divider />
|
||||
<GlobalLabelPreference labelDefinition={LABELS.sexual} />
|
||||
<Divider />
|
||||
<GlobalLabelPreference
|
||||
labelDefinition={LABELS['graphic-media']}
|
||||
/>
|
||||
<Divider />
|
||||
<GlobalLabelPreference labelDefinition={LABELS.nudity} />
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Text
|
||||
style={[
|
||||
|
||||
@@ -15,6 +15,8 @@ import {openCropper} from '#/lib/media/picker'
|
||||
import {getDataUriSize} from '#/lib/media/util'
|
||||
import {useRequestNotificationsPermission} from '#/lib/notifications/notifications'
|
||||
import {logEvent, useGate} from '#/lib/statsig/statsig'
|
||||
import {isCancelledError} from '#/lib/strings/errors'
|
||||
import {logger} from '#/logger'
|
||||
import {isNative, isWeb} from '#/platform/detection'
|
||||
import {
|
||||
DescriptionText,
|
||||
@@ -184,11 +186,17 @@ export function StepProfile() {
|
||||
if (!image) return
|
||||
|
||||
if (!isWeb) {
|
||||
image = await openCropper({
|
||||
imageUri: image.path,
|
||||
shape: 'circle',
|
||||
aspectRatio: 1 / 1,
|
||||
})
|
||||
try {
|
||||
image = await openCropper({
|
||||
imageUri: image.path,
|
||||
shape: 'circle',
|
||||
aspectRatio: 1 / 1,
|
||||
})
|
||||
} catch (e) {
|
||||
if (!isCancelledError(e)) {
|
||||
logger.error('Failed to crop avatar in onboarding', {error: e})
|
||||
}
|
||||
}
|
||||
}
|
||||
image = await compressIfNeeded(image, 1000000)
|
||||
|
||||
|
||||
@@ -312,7 +312,6 @@ function SuggestedProfileCard({
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.w_full,
|
||||
a.py_lg,
|
||||
a.px_xl,
|
||||
|
||||
@@ -570,7 +570,7 @@ function ExpandedPostDetails({
|
||||
<BackdatedPostIndicator post={post} />
|
||||
<View style={[a.flex_row, a.align_center, a.flex_wrap, a.gap_sm]}>
|
||||
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
|
||||
{niceDate(i18n, post.indexedAt, 'medium')}
|
||||
{niceDate(i18n, post.indexedAt, 'dot separated')}
|
||||
</Text>
|
||||
{isRootPost && (
|
||||
<WhoCanReply post={post} isThreadAuthor={isThreadAuthor} />
|
||||
@@ -655,7 +655,7 @@ function BackdatedPostIndicator({post}: {post: AppBskyFeedDefs.PostView}) {
|
||||
a.leading_tight,
|
||||
t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
<Trans>Archived from {niceDate(i18n, createdAt)}</Trans>
|
||||
<Trans>Archived from {niceDate(i18n, createdAt, 'medium')}</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const snoozeBirthdateUpdateAllowedForDid = () => {}
|
||||
@@ -1,11 +0,0 @@
|
||||
import {type ModerationPrefs} from '@atproto/api'
|
||||
|
||||
import {DEFAULT_LOGGED_OUT_LABEL_PREFERENCES} from '#/state/queries/preferences/moderation'
|
||||
|
||||
export const makeAgeRestrictedModerationPrefs = (
|
||||
prefs: ModerationPrefs,
|
||||
): ModerationPrefs => ({
|
||||
...prefs,
|
||||
adultContentEnabled: false,
|
||||
labels: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES,
|
||||
})
|
||||
@@ -1,156 +0,0 @@
|
||||
import {createContext, useContext, useMemo, useState} from 'react'
|
||||
import {type AppBskyUnspeccedDefs} from '@atproto/api'
|
||||
import {useQuery} from '@tanstack/react-query'
|
||||
|
||||
import {networkRetry} from '#/lib/async/retry'
|
||||
import {useGetAndRegisterPushToken} from '#/lib/notifications/notifications'
|
||||
import {isNetworkError} from '#/lib/strings/errors'
|
||||
import {
|
||||
type AgeAssuranceAPIContextType,
|
||||
type AgeAssuranceContextType,
|
||||
} from '#/state/ageAssurance/types'
|
||||
import {useIsAgeAssuranceEnabled} from '#/state/ageAssurance/useIsAgeAssuranceEnabled'
|
||||
import {logger} from '#/state/ageAssurance/util'
|
||||
import {useGeolocationStatus} from '#/state/geolocation'
|
||||
import {useAgent} from '#/state/session'
|
||||
|
||||
export const createAgeAssuranceQueryKey = (did: string) =>
|
||||
['ageAssurance', did] as const
|
||||
|
||||
const DEFAULT_AGE_ASSURANCE_STATE: AppBskyUnspeccedDefs.AgeAssuranceState = {
|
||||
lastInitiatedAt: undefined,
|
||||
status: 'unknown',
|
||||
}
|
||||
|
||||
const AgeAssuranceContext = createContext<AgeAssuranceContextType>({
|
||||
status: 'unknown',
|
||||
isReady: false,
|
||||
lastInitiatedAt: undefined,
|
||||
isAgeRestricted: false,
|
||||
})
|
||||
AgeAssuranceContext.displayName = 'AgeAssuranceContext'
|
||||
|
||||
const AgeAssuranceAPIContext = createContext<AgeAssuranceAPIContextType>({
|
||||
// @ts-ignore can't be bothered to type this
|
||||
refetch: () => Promise.resolve(),
|
||||
})
|
||||
AgeAssuranceAPIContext.displayName = 'AgeAssuranceAPIContext'
|
||||
|
||||
/**
|
||||
* Low-level provider for fetching age assurance state on app load. Do not add
|
||||
* any other data fetching in here to avoid complications and reduced
|
||||
* performance.
|
||||
*/
|
||||
export function Provider({children}: {children: React.ReactNode}) {
|
||||
const agent = useAgent()
|
||||
const {status: geolocation} = useGeolocationStatus()
|
||||
const isAgeAssuranceEnabled = useIsAgeAssuranceEnabled()
|
||||
const getAndRegisterPushToken = useGetAndRegisterPushToken()
|
||||
const [refetchWhilePending, setRefetchWhilePending] = useState(false)
|
||||
|
||||
const {data, isFetched, refetch} = useQuery({
|
||||
/**
|
||||
* This is load bearing. We always want this query to run and end in a
|
||||
* "fetched" state, even if we fall back to defaults. This lets the rest of
|
||||
* the app know that we've at least attempted to load the AA state.
|
||||
*
|
||||
* However, it only needs to run if AA is enabled.
|
||||
*/
|
||||
enabled: isAgeAssuranceEnabled,
|
||||
refetchOnWindowFocus: refetchWhilePending,
|
||||
queryKey: createAgeAssuranceQueryKey(agent.session?.did ?? 'never'),
|
||||
async queryFn() {
|
||||
if (!agent.session) return null
|
||||
|
||||
try {
|
||||
const {data} = await networkRetry(3, () =>
|
||||
agent.app.bsky.unspecced.getAgeAssuranceState(),
|
||||
)
|
||||
// const {data} = {
|
||||
// data: {
|
||||
// lastInitiatedAt: new Date().toISOString(),
|
||||
// status: 'pending',
|
||||
// } as AppBskyUnspeccedDefs.AgeAssuranceState,
|
||||
// }
|
||||
|
||||
logger.debug(`fetch`, {
|
||||
data,
|
||||
account: agent.session?.did,
|
||||
})
|
||||
|
||||
await getAndRegisterPushToken({
|
||||
isAgeRestricted:
|
||||
!!geolocation?.isAgeRestrictedGeo && data.status !== 'assured',
|
||||
})
|
||||
|
||||
return data
|
||||
} catch (e) {
|
||||
if (!isNetworkError(e)) {
|
||||
logger.error(`ageAssurance: failed to fetch`, {safeMessage: e})
|
||||
}
|
||||
// don't re-throw error, we'll just fall back to defaults
|
||||
return null
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* Derive state, or fall back to defaults
|
||||
*/
|
||||
const ageAssuranceContext = useMemo<AgeAssuranceContextType>(() => {
|
||||
const {status, lastInitiatedAt} = data || DEFAULT_AGE_ASSURANCE_STATE
|
||||
const ctx: AgeAssuranceContextType = {
|
||||
isReady: isFetched || !isAgeAssuranceEnabled,
|
||||
status,
|
||||
lastInitiatedAt,
|
||||
isAgeRestricted: isAgeAssuranceEnabled ? status !== 'assured' : false,
|
||||
}
|
||||
logger.debug(`context`, ctx)
|
||||
return ctx
|
||||
}, [isFetched, data, isAgeAssuranceEnabled])
|
||||
|
||||
if (
|
||||
!!ageAssuranceContext.lastInitiatedAt &&
|
||||
ageAssuranceContext.status === 'pending' &&
|
||||
!refetchWhilePending
|
||||
) {
|
||||
/*
|
||||
* If we have a pending state, we want to refetch on window focus to ensure
|
||||
* that we get the latest state when the user returns to the app.
|
||||
*/
|
||||
setRefetchWhilePending(true)
|
||||
} else if (
|
||||
!!ageAssuranceContext.lastInitiatedAt &&
|
||||
ageAssuranceContext.status !== 'pending' &&
|
||||
refetchWhilePending
|
||||
) {
|
||||
setRefetchWhilePending(false)
|
||||
}
|
||||
|
||||
const ageAssuranceAPIContext = useMemo<AgeAssuranceAPIContextType>(
|
||||
() => ({
|
||||
refetch,
|
||||
}),
|
||||
[refetch],
|
||||
)
|
||||
|
||||
return (
|
||||
<AgeAssuranceAPIContext.Provider value={ageAssuranceAPIContext}>
|
||||
<AgeAssuranceContext.Provider value={ageAssuranceContext}>
|
||||
{children}
|
||||
</AgeAssuranceContext.Provider>
|
||||
</AgeAssuranceAPIContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Access to low-level AA state. Prefer using {@link useAgeInfo} for a
|
||||
* more user-friendly interface.
|
||||
*/
|
||||
export function useAgeAssuranceContext() {
|
||||
return useContext(AgeAssuranceContext)
|
||||
}
|
||||
|
||||
export function useAgeAssuranceAPIContext() {
|
||||
return useContext(AgeAssuranceAPIContext)
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import {type AppBskyUnspeccedDefs} from '@atproto/api'
|
||||
import {type QueryObserverBaseResult} from '@tanstack/react-query'
|
||||
|
||||
export type AgeAssuranceContextType = {
|
||||
/**
|
||||
* Whether the age assurance state has been fetched from the server. If user
|
||||
* is not in a region that requires AA, or AA is otherwise disabled, this
|
||||
* will always be `true`.
|
||||
*/
|
||||
isReady: boolean
|
||||
/**
|
||||
* The server-reported status of the user's age verification process.
|
||||
*/
|
||||
status: AppBskyUnspeccedDefs.AgeAssuranceState['status']
|
||||
/**
|
||||
* The last time the age assurance state was attempted by the user.
|
||||
*/
|
||||
lastInitiatedAt: AppBskyUnspeccedDefs.AgeAssuranceState['lastInitiatedAt']
|
||||
/**
|
||||
* Indicates the user is age restricted based on the requirements of their
|
||||
* region, and their server-provided age assurance status. Does not factor in
|
||||
* the user's declared age. If AA is otherise disabled, this will always be
|
||||
* `false`.
|
||||
*/
|
||||
isAgeRestricted: boolean
|
||||
}
|
||||
|
||||
export type AgeAssuranceAPIContextType = {
|
||||
/**
|
||||
* Refreshes the age assurance state by fetching it from the server.
|
||||
*/
|
||||
refetch: QueryObserverBaseResult['refetch']
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import {useMemo} from 'react'
|
||||
|
||||
import {useAgeAssuranceContext} from '#/state/ageAssurance'
|
||||
import {logger} from '#/state/ageAssurance/util'
|
||||
import {usePreferencesQuery} from '#/state/queries/preferences'
|
||||
|
||||
type AgeAssurance = ReturnType<typeof useAgeAssuranceContext> & {
|
||||
/**
|
||||
* The age the user has declared in their preferences, if any.
|
||||
*/
|
||||
declaredAge: number | undefined
|
||||
/**
|
||||
* Indicates whether the user has declared an age under 18.
|
||||
*/
|
||||
isDeclaredUnderage: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Computed age information based on age assurance status and the user's
|
||||
* declared age. Use this instead of {@link useAgeAssuranceContext} to get a
|
||||
* more user-friendly interface.
|
||||
*/
|
||||
export function useAgeAssurance(): AgeAssurance {
|
||||
const aa = useAgeAssuranceContext()
|
||||
const {isFetched: preferencesLoaded, data: preferences} =
|
||||
usePreferencesQuery()
|
||||
const declaredAge = preferences?.userAge
|
||||
|
||||
return useMemo(() => {
|
||||
const isReady = aa.isReady && preferencesLoaded
|
||||
const isDeclaredUnderage =
|
||||
declaredAge !== undefined ? declaredAge < 18 : false
|
||||
const state: AgeAssurance = {
|
||||
isReady,
|
||||
status: aa.status,
|
||||
lastInitiatedAt: aa.lastInitiatedAt,
|
||||
isAgeRestricted: aa.isAgeRestricted,
|
||||
declaredAge,
|
||||
isDeclaredUnderage,
|
||||
}
|
||||
logger.debug(`state`, state)
|
||||
return state
|
||||
}, [aa, preferencesLoaded, declaredAge])
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
import {
|
||||
type AppBskyUnspeccedDefs,
|
||||
type AppBskyUnspeccedInitAgeAssurance,
|
||||
AtpAgent,
|
||||
} from '@atproto/api'
|
||||
import {useMutation, useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {wait} from '#/lib/async/wait'
|
||||
import {
|
||||
// DEV_ENV_APPVIEW,
|
||||
PUBLIC_APPVIEW,
|
||||
PUBLIC_APPVIEW_DID,
|
||||
} from '#/lib/constants'
|
||||
import {isNetworkError} from '#/lib/hooks/useCleanError'
|
||||
import {logger} from '#/logger'
|
||||
import {createAgeAssuranceQueryKey} from '#/state/ageAssurance'
|
||||
import {type DeviceLocation, useGeolocationStatus} from '#/state/geolocation'
|
||||
import {useAgent} from '#/state/session'
|
||||
|
||||
let APPVIEW = PUBLIC_APPVIEW
|
||||
let APPVIEW_DID = PUBLIC_APPVIEW_DID
|
||||
|
||||
/*
|
||||
* Uncomment if using the local dev-env
|
||||
*/
|
||||
// if (__DEV__) {
|
||||
// APPVIEW = DEV_ENV_APPVIEW
|
||||
// /*
|
||||
// * IMPORTANT: you need to get this value from `http://localhost:2581`
|
||||
// * introspection endpoint and updated in `constants`, since it changes
|
||||
// * every time you run the dev-env.
|
||||
// */
|
||||
// APPVIEW_DID = ``
|
||||
// }
|
||||
|
||||
/**
|
||||
* Creates an ISO country code string from the given geolocation data.
|
||||
* Examples: `GB` or `GB-ENG`
|
||||
*/
|
||||
function createISOCountryCode(
|
||||
geolocation: Omit<DeviceLocation, 'countryCode'> & {
|
||||
countryCode: string
|
||||
},
|
||||
): string {
|
||||
return geolocation.countryCode.toUpperCase()
|
||||
}
|
||||
|
||||
export function useInitAgeAssurance() {
|
||||
const qc = useQueryClient()
|
||||
const agent = useAgent()
|
||||
const {status: geolocation} = useGeolocationStatus()
|
||||
return useMutation({
|
||||
async mutationFn(
|
||||
props: Omit<AppBskyUnspeccedInitAgeAssurance.InputSchema, 'countryCode'>,
|
||||
) {
|
||||
const countryCode = geolocation?.countryCode
|
||||
const regionCode = geolocation?.regionCode
|
||||
if (!countryCode) {
|
||||
throw new Error(`Geolocation not available, cannot init age assurance.`)
|
||||
}
|
||||
|
||||
const {
|
||||
data: {token},
|
||||
} = await agent.com.atproto.server.getServiceAuth({
|
||||
aud: APPVIEW_DID,
|
||||
lxm: `app.bsky.unspecced.initAgeAssurance`,
|
||||
})
|
||||
|
||||
const appView = new AtpAgent({service: APPVIEW})
|
||||
appView.sessionManager.session = {...agent.session!}
|
||||
appView.sessionManager.session.accessJwt = token
|
||||
appView.sessionManager.session.refreshJwt = ''
|
||||
|
||||
/*
|
||||
* 2s wait is good actually. Email sending takes a hot sec and this helps
|
||||
* ensure the email is ready for the user once they open their inbox.
|
||||
*/
|
||||
const {data} = await wait(
|
||||
2e3,
|
||||
appView.app.bsky.unspecced.initAgeAssurance({
|
||||
...props,
|
||||
countryCode: createISOCountryCode({
|
||||
countryCode,
|
||||
regionCode,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
qc.setQueryData<AppBskyUnspeccedDefs.AgeAssuranceState>(
|
||||
createAgeAssuranceQueryKey(agent.session?.did ?? 'never'),
|
||||
() => data,
|
||||
)
|
||||
},
|
||||
onError(e) {
|
||||
if (!isNetworkError(e)) {
|
||||
logger.error(`useInitAgeAssurance failed`, {
|
||||
safeMessage: e,
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import {useMemo} from 'react'
|
||||
|
||||
import {useGeolocationStatus} from '#/state/geolocation'
|
||||
|
||||
export function useIsAgeAssuranceEnabled() {
|
||||
const {status: geolocation} = useGeolocationStatus()
|
||||
|
||||
return useMemo(() => {
|
||||
return !!geolocation?.isAgeRestrictedGeo
|
||||
}, [geolocation])
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import {useMemo} from 'react'
|
||||
import {useMutation, useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {preferencesQueryKey} from '#/state/queries/preferences'
|
||||
import {useAgent, useSession} from '#/state/session'
|
||||
import {usePatchAgeAssuranceOtherRequiredData} from '#/ageAssurance'
|
||||
import {IS_DEV} from '#/env'
|
||||
import {account} from '#/storage'
|
||||
|
||||
// 6s in dev, 48h in prod
|
||||
const BIRTHDATE_DELAY_HOURS = IS_DEV ? 0.001 : 48
|
||||
|
||||
/**
|
||||
* Stores the timestamp of the birthday update locally. This is used to
|
||||
* debounce birthday updates globally.
|
||||
*
|
||||
* Use {@link useIsBirthDateUpdateAllowed} to check if an update is allowed.
|
||||
*/
|
||||
export function snoozeBirthdateUpdateAllowedForDid(did: string) {
|
||||
account.set([did, 'birthdateLastUpdatedAt'], new Date().toISOString())
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether a birthdate update is currently allowed, based on the
|
||||
* last update timestamp stored locally.
|
||||
*/
|
||||
export function useIsBirthdateUpdateAllowed() {
|
||||
const {currentAccount} = useSession()
|
||||
return useMemo(() => {
|
||||
if (!currentAccount) return false
|
||||
const lastUpdated = account.get([
|
||||
currentAccount.did,
|
||||
'birthdateLastUpdatedAt',
|
||||
])
|
||||
if (!lastUpdated) return true
|
||||
const lastUpdatedDate = new Date(lastUpdated)
|
||||
const diffMs = Date.now() - lastUpdatedDate.getTime()
|
||||
const diffHours = diffMs / (1000 * 60 * 60)
|
||||
return diffHours >= BIRTHDATE_DELAY_HOURS
|
||||
}, [currentAccount])
|
||||
}
|
||||
|
||||
export function useBirthdateMutation() {
|
||||
const queryClient = useQueryClient()
|
||||
const agent = useAgent()
|
||||
const patchOtherRequiredData = usePatchAgeAssuranceOtherRequiredData()
|
||||
|
||||
return useMutation<void, unknown, {birthDate: Date}>({
|
||||
mutationFn: async ({birthDate}: {birthDate: Date}) => {
|
||||
const bday = birthDate.toISOString()
|
||||
await agent.setPersonalDetails({birthDate: bday})
|
||||
// triggers a refetch
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: preferencesQueryKey,
|
||||
})
|
||||
/**
|
||||
* Also patch the age assurance other required data with the new
|
||||
* birthdate, which may change the user's age assurance access level.
|
||||
*/
|
||||
patchOtherRequiredData({birthdate: bday})
|
||||
snoozeBirthdateUpdateAllowedForDid(agent.sessionManager.did!)
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import {getImageDim} from '#/lib/media/manip'
|
||||
import {openCropper} from '#/lib/media/picker'
|
||||
import {type PickerImage} from '#/lib/media/picker.shared'
|
||||
import {getDataUriSize} from '#/lib/media/util'
|
||||
import {isCancelledError} from '#/lib/strings/errors'
|
||||
import {isNative} from '#/platform/detection'
|
||||
|
||||
export type ImageTransformation = {
|
||||
@@ -143,7 +144,7 @@ export async function cropImage(img: ComposerImage): Promise<ComposerImage> {
|
||||
},
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message.includes('User cancelled')) {
|
||||
if (!isCancelledError(e)) {
|
||||
return img
|
||||
}
|
||||
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
import {networkRetry} from '#/lib/async/retry'
|
||||
import {
|
||||
DEFAULT_GEOLOCATION_CONFIG,
|
||||
GEOLOCATION_CONFIG_URL,
|
||||
} from '#/state/geolocation/const'
|
||||
import {emitGeolocationConfigUpdate} from '#/state/geolocation/events'
|
||||
import {logger} from '#/state/geolocation/logger'
|
||||
import {BAPP_CONFIG_DEV_BYPASS_SECRET, IS_DEV} from '#/env'
|
||||
import {type Device, device} from '#/storage'
|
||||
|
||||
async function getGeolocationConfig(
|
||||
url: string,
|
||||
): Promise<Device['geolocation']> {
|
||||
const res = await fetch(url, {
|
||||
headers: IS_DEV
|
||||
? {
|
||||
'x-dev-bypass-secret': BAPP_CONFIG_DEV_BYPASS_SECRET,
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`config: fetch failed ${res.status}`)
|
||||
}
|
||||
|
||||
const json = await res.json()
|
||||
|
||||
if (json.countryCode) {
|
||||
/**
|
||||
* Only construct known values here, ignore any extras.
|
||||
*/
|
||||
const config: Device['geolocation'] = {
|
||||
countryCode: json.countryCode,
|
||||
regionCode: json.regionCode ?? undefined,
|
||||
ageRestrictedGeos: json.ageRestrictedGeos ?? [],
|
||||
ageBlockedGeos: json.ageBlockedGeos ?? [],
|
||||
}
|
||||
logger.debug(`config: success`)
|
||||
return config
|
||||
} else {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Local promise used within this file only.
|
||||
*/
|
||||
let geolocationConfigResolution: Promise<{success: boolean}> | undefined
|
||||
|
||||
/**
|
||||
* Begin the process of resolving geolocation config. This should be called
|
||||
* once at app start.
|
||||
*
|
||||
* THIS METHOD SHOULD NEVER THROW.
|
||||
*
|
||||
* This method is otherwise not used for any purpose. To ensure geolocation
|
||||
* config is resolved, use {@link ensureGeolocationConfigIsResolved}
|
||||
*/
|
||||
export function beginResolveGeolocationConfig() {
|
||||
/**
|
||||
* Here for debug purposes. Uncomment to prevent hitting the remote geo service, and apply whatever data you require for testing.
|
||||
*/
|
||||
// if (__DEV__) {
|
||||
// geolocationConfigResolution = new Promise(y => y({success: true}))
|
||||
// device.set(['deviceGeolocation'], undefined) // clears GPS data
|
||||
// device.set(['geolocation'], DEFAULT_GEOLOCATION_CONFIG) // clears bapp-config data
|
||||
// return
|
||||
// }
|
||||
|
||||
geolocationConfigResolution = new Promise(async resolve => {
|
||||
let success = true
|
||||
|
||||
try {
|
||||
// Try once, fail fast
|
||||
const config = await getGeolocationConfig(GEOLOCATION_CONFIG_URL)
|
||||
if (config) {
|
||||
device.set(['geolocation'], config)
|
||||
emitGeolocationConfigUpdate(config)
|
||||
} else {
|
||||
// endpoint should throw on all failures, this is insurance
|
||||
throw new Error(
|
||||
`geolocation config: nothing returned from initial request`,
|
||||
)
|
||||
}
|
||||
} catch (e: any) {
|
||||
success = false
|
||||
|
||||
logger.debug(`config: failed initial request`, {
|
||||
safeMessage: e.message,
|
||||
})
|
||||
|
||||
// set to default
|
||||
device.set(['geolocation'], DEFAULT_GEOLOCATION_CONFIG)
|
||||
|
||||
// retry 3 times, but don't await, proceed with default
|
||||
networkRetry(3, () => getGeolocationConfig(GEOLOCATION_CONFIG_URL))
|
||||
.then(config => {
|
||||
if (config) {
|
||||
device.set(['geolocation'], config)
|
||||
emitGeolocationConfigUpdate(config)
|
||||
success = true
|
||||
} else {
|
||||
// endpoint should throw on all failures, this is insurance
|
||||
throw new Error(`config: nothing returned from retries`)
|
||||
}
|
||||
})
|
||||
.catch((e: any) => {
|
||||
// complete fail closed
|
||||
logger.debug(`config: failed retries`, {
|
||||
safeMessage: e.message,
|
||||
})
|
||||
})
|
||||
} finally {
|
||||
resolve({success})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure that geolocation config has been resolved, or at the very least attempted
|
||||
* once. Subsequent retries will not be captured by this `await`. Those will be
|
||||
* reported via {@link emitGeolocationConfigUpdate}.
|
||||
*/
|
||||
export async function ensureGeolocationConfigIsResolved() {
|
||||
if (!geolocationConfigResolution) {
|
||||
throw new Error(`config: beginResolveGeolocationConfig not called yet`)
|
||||
}
|
||||
|
||||
const cached = device.get(['geolocation'])
|
||||
if (cached) {
|
||||
logger.debug(`config: using cache`)
|
||||
} else {
|
||||
logger.debug(`config: no cache`)
|
||||
const {success} = await geolocationConfigResolution
|
||||
if (success) {
|
||||
logger.debug(`config: resolved`)
|
||||
} else {
|
||||
logger.info(`config: failed to resolve`)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import {type GeolocationStatus} from '#/state/geolocation/types'
|
||||
import {BAPP_CONFIG_DEV_URL, IS_DEV} from '#/env'
|
||||
import {type Device} from '#/storage'
|
||||
|
||||
export const IPCC_URL = `https://bsky.app/ipcc`
|
||||
export const BAPP_CONFIG_URL_PROD = `https://ip.bsky.app/config`
|
||||
export const BAPP_CONFIG_URL = IS_DEV
|
||||
? (BAPP_CONFIG_DEV_URL ?? BAPP_CONFIG_URL_PROD)
|
||||
: BAPP_CONFIG_URL_PROD
|
||||
export const GEOLOCATION_CONFIG_URL = BAPP_CONFIG_URL
|
||||
|
||||
/**
|
||||
* Default geolocation config.
|
||||
*/
|
||||
export const DEFAULT_GEOLOCATION_CONFIG: Device['geolocation'] = {
|
||||
countryCode: undefined,
|
||||
regionCode: undefined,
|
||||
ageRestrictedGeos: [],
|
||||
ageBlockedGeos: [],
|
||||
}
|
||||
|
||||
/**
|
||||
* Default geolocation status.
|
||||
*/
|
||||
export const DEFAULT_GEOLOCATION_STATUS: GeolocationStatus = {
|
||||
countryCode: undefined,
|
||||
regionCode: undefined,
|
||||
isAgeRestrictedGeo: false,
|
||||
isAgeBlockedGeo: false,
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import EventEmitter from 'eventemitter3'
|
||||
|
||||
import {type Device} from '#/storage'
|
||||
|
||||
const events = new EventEmitter()
|
||||
const EVENT = 'geolocation-config-updated'
|
||||
|
||||
export const emitGeolocationConfigUpdate = (config: Device['geolocation']) => {
|
||||
events.emit(EVENT, config)
|
||||
}
|
||||
|
||||
export const onGeolocationConfigUpdate = (
|
||||
listener: (config: Device['geolocation']) => void,
|
||||
) => {
|
||||
events.on(EVENT, listener)
|
||||
return () => {
|
||||
events.off(EVENT, listener)
|
||||
}
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
import React from 'react'
|
||||
|
||||
import {
|
||||
DEFAULT_GEOLOCATION_CONFIG,
|
||||
DEFAULT_GEOLOCATION_STATUS,
|
||||
} from '#/state/geolocation/const'
|
||||
import {onGeolocationConfigUpdate} from '#/state/geolocation/events'
|
||||
import {logger} from '#/state/geolocation/logger'
|
||||
import {
|
||||
type DeviceLocation,
|
||||
type GeolocationStatus,
|
||||
} from '#/state/geolocation/types'
|
||||
import {useSyncedDeviceGeolocation} from '#/state/geolocation/useSyncedDeviceGeolocation'
|
||||
import {
|
||||
computeGeolocationStatus,
|
||||
mergeGeolocation,
|
||||
} from '#/state/geolocation/util'
|
||||
import {type Device, device} from '#/storage'
|
||||
|
||||
export * from '#/state/geolocation/config'
|
||||
export * from '#/state/geolocation/types'
|
||||
export * from '#/state/geolocation/util'
|
||||
|
||||
type DeviceGeolocationContext = {
|
||||
deviceGeolocation: DeviceLocation | undefined
|
||||
}
|
||||
|
||||
type DeviceGeolocationAPIContext = {
|
||||
setDeviceGeolocation(deviceGeolocation: DeviceLocation): void
|
||||
}
|
||||
|
||||
type GeolocationConfigContext = {
|
||||
config: Device['geolocation']
|
||||
}
|
||||
|
||||
type GeolocationStatusContext = {
|
||||
/**
|
||||
* Merged geolocation from config and device GPS (if available).
|
||||
*/
|
||||
location: DeviceLocation
|
||||
/**
|
||||
* Computed geolocation status based on the merged location and config.
|
||||
*/
|
||||
status: GeolocationStatus
|
||||
}
|
||||
|
||||
const DeviceGeolocationContext = React.createContext<DeviceGeolocationContext>({
|
||||
deviceGeolocation: undefined,
|
||||
})
|
||||
DeviceGeolocationContext.displayName = 'DeviceGeolocationContext'
|
||||
|
||||
const DeviceGeolocationAPIContext =
|
||||
React.createContext<DeviceGeolocationAPIContext>({
|
||||
setDeviceGeolocation: () => {},
|
||||
})
|
||||
DeviceGeolocationAPIContext.displayName = 'DeviceGeolocationAPIContext'
|
||||
|
||||
const GeolocationConfigContext = React.createContext<GeolocationConfigContext>({
|
||||
config: DEFAULT_GEOLOCATION_CONFIG,
|
||||
})
|
||||
GeolocationConfigContext.displayName = 'GeolocationConfigContext'
|
||||
|
||||
const GeolocationStatusContext = React.createContext<GeolocationStatusContext>({
|
||||
location: {
|
||||
countryCode: undefined,
|
||||
regionCode: undefined,
|
||||
},
|
||||
status: DEFAULT_GEOLOCATION_STATUS,
|
||||
})
|
||||
GeolocationStatusContext.displayName = 'GeolocationStatusContext'
|
||||
|
||||
/**
|
||||
* Provider of geolocation config and computed geolocation status.
|
||||
*/
|
||||
export function GeolocationStatusProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const {deviceGeolocation} = React.useContext(DeviceGeolocationContext)
|
||||
const [config, setConfig] = React.useState(() => {
|
||||
const initial = device.get(['geolocation']) || DEFAULT_GEOLOCATION_CONFIG
|
||||
return initial
|
||||
})
|
||||
|
||||
React.useEffect(() => {
|
||||
return onGeolocationConfigUpdate(config => {
|
||||
setConfig(config!)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const configContext = React.useMemo(() => ({config}), [config])
|
||||
const statusContext = React.useMemo(() => {
|
||||
if (deviceGeolocation?.countryCode) {
|
||||
logger.debug('has device geolocation available')
|
||||
}
|
||||
const geolocation = mergeGeolocation(deviceGeolocation, config)
|
||||
const status = computeGeolocationStatus(geolocation, config)
|
||||
// ensure this remains debug and never leaves device
|
||||
logger.debug('result', {deviceGeolocation, geolocation, status, config})
|
||||
return {location: geolocation, status}
|
||||
}, [config, deviceGeolocation])
|
||||
|
||||
return (
|
||||
<GeolocationConfigContext.Provider value={configContext}>
|
||||
<GeolocationStatusContext.Provider value={statusContext}>
|
||||
{children}
|
||||
</GeolocationStatusContext.Provider>
|
||||
</GeolocationConfigContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider of providers. Provides device geolocation data to lower-level
|
||||
* `GeolocationStatusProvider`, and device geolocation APIs to children.
|
||||
*/
|
||||
export function Provider({children}: {children: React.ReactNode}) {
|
||||
const [deviceGeolocation, setDeviceGeolocation] = useSyncedDeviceGeolocation()
|
||||
|
||||
const handleSetDeviceGeolocation = React.useCallback(
|
||||
(location: DeviceLocation) => {
|
||||
logger.debug('setting device geolocation')
|
||||
setDeviceGeolocation({
|
||||
countryCode: location.countryCode ?? undefined,
|
||||
regionCode: location.regionCode ?? undefined,
|
||||
})
|
||||
},
|
||||
[setDeviceGeolocation],
|
||||
)
|
||||
|
||||
return (
|
||||
<DeviceGeolocationAPIContext.Provider
|
||||
value={React.useMemo(
|
||||
() => ({setDeviceGeolocation: handleSetDeviceGeolocation}),
|
||||
[handleSetDeviceGeolocation],
|
||||
)}>
|
||||
<DeviceGeolocationContext.Provider
|
||||
value={React.useMemo(() => ({deviceGeolocation}), [deviceGeolocation])}>
|
||||
<GeolocationStatusProvider>{children}</GeolocationStatusProvider>
|
||||
</DeviceGeolocationContext.Provider>
|
||||
</DeviceGeolocationAPIContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useDeviceGeolocationApi() {
|
||||
return React.useContext(DeviceGeolocationAPIContext)
|
||||
}
|
||||
|
||||
export function useGeolocationConfig() {
|
||||
return React.useContext(GeolocationConfigContext)
|
||||
}
|
||||
|
||||
export function useGeolocationStatus() {
|
||||
return React.useContext(GeolocationStatusContext)
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
export type DeviceLocation = {
|
||||
countryCode: string | undefined
|
||||
regionCode: string | undefined
|
||||
}
|
||||
|
||||
export type GeolocationStatus = DeviceLocation & {
|
||||
isAgeRestrictedGeo: boolean
|
||||
isAgeBlockedGeo: boolean
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import {useCallback} from 'react'
|
||||
import * as Location from 'expo-location'
|
||||
|
||||
import {type DeviceLocation} from '#/state/geolocation/types'
|
||||
import {getDeviceGeolocation} from '#/state/geolocation/util'
|
||||
|
||||
export {PermissionStatus} from 'expo-location'
|
||||
|
||||
export function useRequestDeviceLocation(): () => Promise<
|
||||
| {
|
||||
granted: true
|
||||
location: DeviceLocation | undefined
|
||||
}
|
||||
| {
|
||||
granted: false
|
||||
status: {
|
||||
canAskAgain: boolean
|
||||
/**
|
||||
* Enum, use `PermissionStatus` export for comparisons
|
||||
*/
|
||||
permissionStatus: Location.PermissionStatus
|
||||
}
|
||||
}
|
||||
> {
|
||||
return useCallback(async () => {
|
||||
const status = await Location.requestForegroundPermissionsAsync()
|
||||
|
||||
if (status.granted) {
|
||||
return {
|
||||
granted: true,
|
||||
location: await getDeviceGeolocation(),
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
granted: false,
|
||||
status: {
|
||||
canAskAgain: status.canAskAgain,
|
||||
permissionStatus: status.status,
|
||||
},
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
import {useEffect, useRef} from 'react'
|
||||
import * as Location from 'expo-location'
|
||||
import {createPermissionHook} from 'expo-modules-core'
|
||||
|
||||
import {logger} from '#/state/geolocation/logger'
|
||||
import {getDeviceGeolocation} from '#/state/geolocation/util'
|
||||
import {device, useStorage} from '#/storage'
|
||||
|
||||
/**
|
||||
* Location.useForegroundPermissions on web just errors if the navigator.permissions API is not available.
|
||||
* We need to catch and ignore it, since it's effectively denied.
|
||||
* @see https://github.com/expo/expo/blob/72f1562ed9cce5ff6dfe04aa415b71632a3d4b87/packages/expo-location/src/Location.ts#L290-L293
|
||||
*/
|
||||
const useForegroundPermissions = createPermissionHook({
|
||||
getMethod: () =>
|
||||
Location.getForegroundPermissionsAsync().catch(error => {
|
||||
logger.debug(
|
||||
'useForegroundPermission: error getting location permissions',
|
||||
{safeMessage: error},
|
||||
)
|
||||
return {
|
||||
status: Location.PermissionStatus.DENIED,
|
||||
granted: false,
|
||||
canAskAgain: false,
|
||||
expires: 0,
|
||||
}
|
||||
}),
|
||||
requestMethod: () =>
|
||||
Location.requestForegroundPermissionsAsync().catch(error => {
|
||||
logger.debug(
|
||||
'useForegroundPermission: error requesting location permissions',
|
||||
{safeMessage: error},
|
||||
)
|
||||
return {
|
||||
status: Location.PermissionStatus.DENIED,
|
||||
granted: false,
|
||||
canAskAgain: false,
|
||||
expires: 0,
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
/**
|
||||
* Hook to get and sync the device geolocation from the device GPS and store it
|
||||
* using device storage. If permissions are not granted, it will clear any cached
|
||||
* storage value.
|
||||
*/
|
||||
export function useSyncedDeviceGeolocation() {
|
||||
const synced = useRef(false)
|
||||
const [status] = useForegroundPermissions()
|
||||
const [deviceGeolocation, setDeviceGeolocation] = useStorage(device, [
|
||||
'deviceGeolocation',
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
async function get() {
|
||||
// no need to set this more than once per session
|
||||
if (synced.current) return
|
||||
|
||||
logger.debug('useSyncedDeviceGeolocation: checking perms')
|
||||
|
||||
if (status?.granted) {
|
||||
const location = await getDeviceGeolocation()
|
||||
if (location) {
|
||||
logger.debug('useSyncedDeviceGeolocation: syncing location')
|
||||
setDeviceGeolocation(location)
|
||||
synced.current = true
|
||||
}
|
||||
} else {
|
||||
const hasCachedValue = device.get(['deviceGeolocation']) !== undefined
|
||||
|
||||
/**
|
||||
* If we have a cached value, but user has revoked permissions,
|
||||
* quietly (will take effect lazily) clear this out.
|
||||
*/
|
||||
if (hasCachedValue) {
|
||||
logger.debug(
|
||||
'useSyncedDeviceGeolocation: clearing cached location, perms revoked',
|
||||
)
|
||||
device.set(['deviceGeolocation'], undefined)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get().catch(e => {
|
||||
logger.error('useSyncedDeviceGeolocation: failed to sync', {
|
||||
safeMessage: e,
|
||||
})
|
||||
})
|
||||
}, [status, setDeviceGeolocation])
|
||||
|
||||
return [deviceGeolocation, setDeviceGeolocation] as const
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
import {
|
||||
getCurrentPositionAsync,
|
||||
type LocationGeocodedAddress,
|
||||
reverseGeocodeAsync,
|
||||
} from 'expo-location'
|
||||
|
||||
import {logger} from '#/state/geolocation/logger'
|
||||
import {type DeviceLocation} from '#/state/geolocation/types'
|
||||
import {type Device} from '#/storage'
|
||||
|
||||
/**
|
||||
* Maps full US region names to their short codes.
|
||||
*
|
||||
* Context: in some cases, like on Android, we get the full region name instead
|
||||
* of the short code. We may need to expand this in the future to other
|
||||
* countries, hence the prefix.
|
||||
*/
|
||||
export const USRegionNameToRegionCode: {
|
||||
[regionName: string]: string
|
||||
} = {
|
||||
Alabama: 'AL',
|
||||
Alaska: 'AK',
|
||||
Arizona: 'AZ',
|
||||
Arkansas: 'AR',
|
||||
California: 'CA',
|
||||
Colorado: 'CO',
|
||||
Connecticut: 'CT',
|
||||
Delaware: 'DE',
|
||||
Florida: 'FL',
|
||||
Georgia: 'GA',
|
||||
Hawaii: 'HI',
|
||||
Idaho: 'ID',
|
||||
Illinois: 'IL',
|
||||
Indiana: 'IN',
|
||||
Iowa: 'IA',
|
||||
Kansas: 'KS',
|
||||
Kentucky: 'KY',
|
||||
Louisiana: 'LA',
|
||||
Maine: 'ME',
|
||||
Maryland: 'MD',
|
||||
Massachusetts: 'MA',
|
||||
Michigan: 'MI',
|
||||
Minnesota: 'MN',
|
||||
Mississippi: 'MS',
|
||||
Missouri: 'MO',
|
||||
Montana: 'MT',
|
||||
Nebraska: 'NE',
|
||||
Nevada: 'NV',
|
||||
['New Hampshire']: 'NH',
|
||||
['New Jersey']: 'NJ',
|
||||
['New Mexico']: 'NM',
|
||||
['New York']: 'NY',
|
||||
['North Carolina']: 'NC',
|
||||
['North Dakota']: 'ND',
|
||||
Ohio: 'OH',
|
||||
Oklahoma: 'OK',
|
||||
Oregon: 'OR',
|
||||
Pennsylvania: 'PA',
|
||||
['Rhode Island']: 'RI',
|
||||
['South Carolina']: 'SC',
|
||||
['South Dakota']: 'SD',
|
||||
Tennessee: 'TN',
|
||||
Texas: 'TX',
|
||||
Utah: 'UT',
|
||||
Vermont: 'VT',
|
||||
Virginia: 'VA',
|
||||
Washington: 'WA',
|
||||
['West Virginia']: 'WV',
|
||||
Wisconsin: 'WI',
|
||||
Wyoming: 'WY',
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a `LocationGeocodedAddress` into a `DeviceLocation`.
|
||||
*
|
||||
* We don't want or care about the full location data, so we trim it down and
|
||||
* normalize certain fields, like region, into the format we need.
|
||||
*/
|
||||
export function normalizeDeviceLocation(
|
||||
location: LocationGeocodedAddress,
|
||||
): DeviceLocation {
|
||||
let {isoCountryCode, region} = location
|
||||
|
||||
if (region) {
|
||||
if (isoCountryCode === 'US') {
|
||||
region = USRegionNameToRegionCode[region] ?? region
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
countryCode: isoCountryCode ?? undefined,
|
||||
regionCode: region ?? undefined,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Combines precise location data with the geolocation config fetched from the
|
||||
* IP service, with preference to the precise data.
|
||||
*/
|
||||
export function mergeGeolocation(
|
||||
location?: DeviceLocation,
|
||||
config?: Device['geolocation'],
|
||||
): DeviceLocation {
|
||||
if (location?.countryCode) return location
|
||||
return {
|
||||
countryCode: config?.countryCode,
|
||||
regionCode: config?.regionCode,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the geolocation status (age-restricted, age-blocked) based on the
|
||||
* given location and geolocation config. `location` here should be merged with
|
||||
* `mergeGeolocation()` ahead of time if needed.
|
||||
*/
|
||||
export function computeGeolocationStatus(
|
||||
location: DeviceLocation,
|
||||
config: Device['geolocation'],
|
||||
) {
|
||||
/**
|
||||
* We can't do anything if we don't have this data.
|
||||
*/
|
||||
if (!location.countryCode) {
|
||||
return {
|
||||
...location,
|
||||
isAgeRestrictedGeo: false,
|
||||
isAgeBlockedGeo: false,
|
||||
}
|
||||
}
|
||||
|
||||
const isAgeRestrictedGeo = config?.ageRestrictedGeos?.some(rule => {
|
||||
if (rule.countryCode === location.countryCode) {
|
||||
if (!rule.regionCode) {
|
||||
return true // whole country is blocked
|
||||
} else if (rule.regionCode === location.regionCode) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const isAgeBlockedGeo = config?.ageBlockedGeos?.some(rule => {
|
||||
if (rule.countryCode === location.countryCode) {
|
||||
if (!rule.regionCode) {
|
||||
return true // whole country is blocked
|
||||
} else if (rule.regionCode === location.regionCode) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
...location,
|
||||
isAgeRestrictedGeo: !!isAgeRestrictedGeo,
|
||||
isAgeBlockedGeo: !!isAgeBlockedGeo,
|
||||
}
|
||||
}
|
||||
|
||||
export async function getDeviceGeolocation(): Promise<DeviceLocation> {
|
||||
try {
|
||||
const geocode = await getCurrentPositionAsync()
|
||||
const locations = await reverseGeocodeAsync({
|
||||
latitude: geocode.coords.latitude,
|
||||
longitude: geocode.coords.longitude,
|
||||
})
|
||||
const location = locations.at(0)
|
||||
const normalized = location ? normalizeDeviceLocation(location) : undefined
|
||||
return {
|
||||
countryCode: normalized?.countryCode ?? undefined,
|
||||
regionCode: normalized?.regionCode ?? undefined,
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('getDeviceGeolocation: failed', {
|
||||
safeMessage: e,
|
||||
})
|
||||
return {
|
||||
countryCode: undefined,
|
||||
regionCode: undefined,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,6 @@ import {aggregateUserInterests} from '#/lib/api/feed/utils'
|
||||
import {FeedTuner, type FeedTunerFn} from '#/lib/api/feed-manip'
|
||||
import {DISCOVER_FEED_URI} from '#/lib/constants'
|
||||
import {logger} from '#/logger'
|
||||
import {useAgeAssuranceContext} from '#/state/ageAssurance'
|
||||
import {STALE} from '#/state/queries'
|
||||
import {DEFAULT_LOGGED_OUT_PREFERENCES} from '#/state/queries/preferences/const'
|
||||
import {useAgent} from '#/state/session'
|
||||
@@ -141,12 +140,8 @@ export function usePostFeedQuery(
|
||||
* available for the remainder of the session, so this delay only affects cold
|
||||
* loads. -esb
|
||||
*/
|
||||
const {isReady: isAgeAssuranceReady} = useAgeAssuranceContext()
|
||||
const enabled =
|
||||
opts?.enabled !== false &&
|
||||
Boolean(moderationOpts) &&
|
||||
Boolean(preferences) &&
|
||||
isAgeAssuranceReady
|
||||
opts?.enabled !== false && Boolean(moderationOpts) && Boolean(preferences)
|
||||
const userInterests = aggregateUserInterests(preferences)
|
||||
const followingPinnedIndex =
|
||||
preferences?.savedFeeds?.findIndex(
|
||||
|
||||
@@ -26,6 +26,7 @@ export function usePostQuery(uri: string | undefined) {
|
||||
const res = await agent.resolveHandle({
|
||||
handle: urip.host,
|
||||
})
|
||||
// @ts-expect-error TODO new-sdk-migration
|
||||
urip.host = res.data.did
|
||||
}
|
||||
|
||||
@@ -54,6 +55,7 @@ export function useGetPost() {
|
||||
const res = await agent.resolveHandle({
|
||||
handle: urip.host,
|
||||
})
|
||||
// @ts-expect-error TODO new-sdk-migration
|
||||
urip.host = res.data.did
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ export async function getPostgateRecord({
|
||||
const res = await agent.resolveHandle({
|
||||
handle: urip.host,
|
||||
})
|
||||
// @ts-expect-error TODO new-sdk-migration
|
||||
urip.host = res.data.did
|
||||
}
|
||||
|
||||
|
||||
@@ -10,8 +10,6 @@ import {PROD_DEFAULT_FEED} from '#/lib/constants'
|
||||
import {replaceEqualDeep} from '#/lib/functions'
|
||||
import {getAge} from '#/lib/strings/time'
|
||||
import {logger} from '#/logger'
|
||||
import {useAgeAssuranceContext} from '#/state/ageAssurance'
|
||||
import {makeAgeRestrictedModerationPrefs} from '#/state/ageAssurance/const'
|
||||
import {STALE} from '#/state/queries'
|
||||
import {
|
||||
DEFAULT_HOME_FEED_PREFS,
|
||||
@@ -24,6 +22,7 @@ import {
|
||||
} from '#/state/queries/preferences/types'
|
||||
import {useAgent} from '#/state/session'
|
||||
import {saveLabelers} from '#/state/session/agent-config'
|
||||
import {useAgeAssurance} from '#/ageAssurance'
|
||||
|
||||
export * from '#/state/queries/preferences/const'
|
||||
export * from '#/state/queries/preferences/moderation'
|
||||
@@ -34,7 +33,7 @@ export const preferencesQueryKey = [preferencesQueryKeyRoot]
|
||||
|
||||
export function usePreferencesQuery() {
|
||||
const agent = useAgent()
|
||||
const {isAgeRestricted} = useAgeAssuranceContext()
|
||||
const aa = useAgeAssurance()
|
||||
|
||||
return useQuery({
|
||||
staleTime: STALE.SECONDS.FIFTEEN,
|
||||
@@ -75,18 +74,19 @@ export function usePreferencesQuery() {
|
||||
},
|
||||
select: useCallback(
|
||||
(data: UsePreferencesQueryResponse) => {
|
||||
const isUnderage = (data.userAge || 0) < 18
|
||||
if (isUnderage || isAgeRestricted) {
|
||||
/**
|
||||
* Prefs are all downstream of age assurance now. For logged-out
|
||||
* users, we override moderation prefs based on AA state.
|
||||
*/
|
||||
if (aa.state.access !== aa.Access.Full) {
|
||||
data = {
|
||||
...data,
|
||||
moderationPrefs: makeAgeRestrictedModerationPrefs(
|
||||
data.moderationPrefs,
|
||||
),
|
||||
moderationPrefs: DEFAULT_LOGGED_OUT_PREFERENCES.moderationPrefs,
|
||||
}
|
||||
}
|
||||
return data
|
||||
},
|
||||
[isAgeRestricted],
|
||||
[aa],
|
||||
),
|
||||
})
|
||||
}
|
||||
@@ -168,21 +168,6 @@ export function usePreferencesSetAdultContentMutation() {
|
||||
})
|
||||
}
|
||||
|
||||
export function usePreferencesSetBirthDateMutation() {
|
||||
const queryClient = useQueryClient()
|
||||
const agent = useAgent()
|
||||
|
||||
return useMutation<void, unknown, {birthDate: Date}>({
|
||||
mutationFn: async ({birthDate}: {birthDate: Date}) => {
|
||||
await agent.setPersonalDetails({birthDate: birthDate.toISOString()})
|
||||
// triggers a refetch
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: preferencesQueryKey,
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useSetFeedViewPreferencesMutation() {
|
||||
const queryClient = useQueryClient()
|
||||
const agent = useAgent()
|
||||
|
||||
@@ -17,6 +17,7 @@ export function useResolveUriQuery(uri: string | undefined): UriUseQueryResult {
|
||||
const urip = new AtUri(uri || '')
|
||||
const res = useResolveDidQuery(urip.host)
|
||||
if (res.data) {
|
||||
// @ts-expect-error TODO new-sdk-migration
|
||||
urip.host = res.data
|
||||
return {
|
||||
...res,
|
||||
|
||||
@@ -97,6 +97,7 @@ export async function getThreadgateRecord({
|
||||
const res = await agent.resolveHandle({
|
||||
handle: urip.host,
|
||||
})
|
||||
// @ts-expect-error TODO new-sdk-migration
|
||||
urip.host = res.data.did
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,9 @@ jest.mock('jwt-decode', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('../../birthdate')
|
||||
jest.mock('../../../ageAssurance/data')
|
||||
|
||||
describe('session', () => {
|
||||
it('can log in and out', () => {
|
||||
let state = getInitialState([])
|
||||
|
||||
+143
-40
@@ -1,10 +1,12 @@
|
||||
import {
|
||||
Agent as BaseAgent,
|
||||
type AppBskyActorProfile,
|
||||
type AtprotoServiceType,
|
||||
type AtpSessionData,
|
||||
type AtpSessionEvent,
|
||||
BskyAgent,
|
||||
type Did,
|
||||
type Un$Typed,
|
||||
} from '@atproto/api'
|
||||
import {type FetchHandler} from '@atproto/api/dist/agent'
|
||||
import {type SessionManager} from '@atproto/api/dist/session-manager'
|
||||
@@ -23,7 +25,13 @@ import {
|
||||
import {tryFetchGates} from '#/lib/statsig/statsig'
|
||||
import {getAge} from '#/lib/strings/time'
|
||||
import {logger} from '#/logger'
|
||||
import {snoozeBirthdateUpdateAllowedForDid} from '#/state/birthdate'
|
||||
import {snoozeEmailConfirmationPrompt} from '#/state/shell/reminders'
|
||||
import {
|
||||
prefetchAgeAssuranceData,
|
||||
setBirthdateForDid,
|
||||
setCreatedAtForDid,
|
||||
} from '#/ageAssurance/data'
|
||||
import {emitNetworkConfirmed, emitNetworkLost} from '../events'
|
||||
import {addSessionErrorLog} from './logging'
|
||||
import {
|
||||
@@ -77,9 +85,15 @@ export async function createAgentAndResume(
|
||||
}
|
||||
}
|
||||
|
||||
// after session is attached
|
||||
const aa = prefetchAgeAssuranceData({agent})
|
||||
|
||||
agent.configureProxy(BLUESKY_PROXY_HEADER.get())
|
||||
|
||||
return agent.prepare(gates, moderation, onSessionChange)
|
||||
return agent.prepare({
|
||||
resolvers: [gates, moderation, aa],
|
||||
onSessionChange,
|
||||
})
|
||||
}
|
||||
|
||||
export async function createAgentAndLogin(
|
||||
@@ -111,10 +125,14 @@ export async function createAgentAndLogin(
|
||||
const account = agentToSessionAccountOrThrow(agent)
|
||||
const gates = tryFetchGates(account.did, 'prefer-fresh-gates')
|
||||
const moderation = configureModerationForAccount(agent, account)
|
||||
const aa = prefetchAgeAssuranceData({agent})
|
||||
|
||||
agent.configureProxy(BLUESKY_PROXY_HEADER.get())
|
||||
|
||||
return agent.prepare(gates, moderation, onSessionChange)
|
||||
return agent.prepare({
|
||||
resolvers: [gates, moderation, aa],
|
||||
onSessionChange,
|
||||
})
|
||||
}
|
||||
|
||||
export async function createAgentAndCreateAccount(
|
||||
@@ -156,42 +174,122 @@ export async function createAgentAndCreateAccount(
|
||||
const gates = tryFetchGates(account.did, 'prefer-fresh-gates')
|
||||
const moderation = configureModerationForAccount(agent, account)
|
||||
|
||||
const createdAt = new Date().toISOString()
|
||||
const birthdate = birthDate.toISOString()
|
||||
|
||||
/*
|
||||
* Since we have a race with account creation, profile creation, and AA
|
||||
* state, set these values locally to ensure sync reads. Values are written
|
||||
* to the server in the next step, so on subsequent reloads, the server will
|
||||
* be the source of truth.
|
||||
*/
|
||||
setCreatedAtForDid({did: account.did, createdAt})
|
||||
setBirthdateForDid({did: account.did, birthdate})
|
||||
snoozeBirthdateUpdateAllowedForDid(account.did)
|
||||
// do this last
|
||||
const aa = prefetchAgeAssuranceData({agent})
|
||||
|
||||
// Not awaited so that we can still get into onboarding.
|
||||
// This is OK because we won't let you toggle adult stuff until you set the date.
|
||||
if (IS_PROD_SERVICE(service)) {
|
||||
try {
|
||||
networkRetry(1, async () => {
|
||||
await agent.setPersonalDetails({birthDate: birthDate.toISOString()})
|
||||
await agent.overwriteSavedFeeds([
|
||||
{
|
||||
...DISCOVER_SAVED_FEED,
|
||||
id: TID.nextStr(),
|
||||
},
|
||||
{
|
||||
...TIMELINE_SAVED_FEED,
|
||||
id: TID.nextStr(),
|
||||
},
|
||||
])
|
||||
|
||||
if (getAge(birthDate) < 18) {
|
||||
await agent.api.com.atproto.repo.putRecord({
|
||||
repo: account.did,
|
||||
collection: 'chat.bsky.actor.declaration',
|
||||
rkey: 'self',
|
||||
record: {
|
||||
$type: 'chat.bsky.actor.declaration',
|
||||
allowIncoming: 'none',
|
||||
},
|
||||
Promise.allSettled(
|
||||
[
|
||||
networkRetry(3, () => {
|
||||
return agent.setPersonalDetails({
|
||||
birthDate: birthdate,
|
||||
})
|
||||
}
|
||||
})
|
||||
} catch (e: any) {
|
||||
logger.error(e, {
|
||||
message: `session: createAgentAndCreateAccount failed to save personal details and feeds`,
|
||||
})
|
||||
}
|
||||
}).catch(e => {
|
||||
logger.info(`createAgentAndCreateAccount: failed to set birthDate`)
|
||||
throw e
|
||||
}),
|
||||
networkRetry(3, () => {
|
||||
return agent.upsertProfile(prev => {
|
||||
const next: Un$Typed<AppBskyActorProfile.Record> = prev || {}
|
||||
next.displayName = handle
|
||||
next.createdAt = createdAt
|
||||
return next
|
||||
})
|
||||
}).catch(e => {
|
||||
logger.info(
|
||||
`createAgentAndCreateAccount: failed to set initial profile`,
|
||||
)
|
||||
throw e
|
||||
}),
|
||||
networkRetry(1, () => {
|
||||
return agent.overwriteSavedFeeds([
|
||||
{
|
||||
...DISCOVER_SAVED_FEED,
|
||||
id: TID.nextStr(),
|
||||
},
|
||||
{
|
||||
...TIMELINE_SAVED_FEED,
|
||||
id: TID.nextStr(),
|
||||
},
|
||||
])
|
||||
}).catch(e => {
|
||||
logger.info(
|
||||
`createAgentAndCreateAccount: failed to set initial feeds`,
|
||||
)
|
||||
throw e
|
||||
}),
|
||||
getAge(birthDate) < 18 &&
|
||||
networkRetry(3, () => {
|
||||
return agent.com.atproto.repo.putRecord({
|
||||
repo: account.did,
|
||||
collection: 'chat.bsky.actor.declaration',
|
||||
rkey: 'self',
|
||||
record: {
|
||||
$type: 'chat.bsky.actor.declaration',
|
||||
allowIncoming: 'none',
|
||||
},
|
||||
})
|
||||
}).catch(e => {
|
||||
logger.info(
|
||||
`createAgentAndCreateAccount: failed to set chat declaration`,
|
||||
)
|
||||
throw e
|
||||
}),
|
||||
].filter(Boolean),
|
||||
).then(promises => {
|
||||
const rejected = promises.filter(p => p.status === 'rejected')
|
||||
if (rejected.length > 0) {
|
||||
logger.error(
|
||||
`session: createAgentAndCreateAccount failed to save personal details and feeds`,
|
||||
)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
agent.setPersonalDetails({birthDate: birthDate.toISOString()})
|
||||
Promise.allSettled(
|
||||
[
|
||||
networkRetry(3, () => {
|
||||
return agent.setPersonalDetails({
|
||||
birthDate: birthDate.toISOString(),
|
||||
})
|
||||
}).catch(e => {
|
||||
logger.info(`createAgentAndCreateAccount: failed to set birthDate`)
|
||||
throw e
|
||||
}),
|
||||
networkRetry(3, () => {
|
||||
return agent.upsertProfile(prev => {
|
||||
const next: Un$Typed<AppBskyActorProfile.Record> = prev || {}
|
||||
next.createdAt = prev?.createdAt || new Date().toISOString()
|
||||
return next
|
||||
})
|
||||
}).catch(e => {
|
||||
logger.info(
|
||||
`createAgentAndCreateAccount: failed to set initial profile`,
|
||||
)
|
||||
throw e
|
||||
}),
|
||||
].filter(Boolean),
|
||||
).then(promises => {
|
||||
const rejected = promises.filter(p => p.status === 'rejected')
|
||||
if (rejected.length > 0) {
|
||||
logger.error(
|
||||
`session: createAgentAndCreateAccount failed to save personal details and feeds`,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -203,7 +301,10 @@ export async function createAgentAndCreateAccount(
|
||||
|
||||
agent.configureProxy(BLUESKY_PROXY_HEADER.get())
|
||||
|
||||
return agent.prepare(gates, moderation, onSessionChange)
|
||||
return agent.prepare({
|
||||
resolvers: [gates, moderation, aa],
|
||||
onSessionChange,
|
||||
})
|
||||
}
|
||||
|
||||
export function agentToSessionAccountOrThrow(agent: BskyAgent): SessionAccount {
|
||||
@@ -306,18 +407,20 @@ class BskyAppAgent extends BskyAgent {
|
||||
})
|
||||
}
|
||||
|
||||
async prepare(
|
||||
async prepare({
|
||||
resolvers,
|
||||
onSessionChange,
|
||||
}: {
|
||||
// Not awaited in the calling code so we can delay blocking on them.
|
||||
gates: Promise<void>,
|
||||
moderation: Promise<void>,
|
||||
resolvers: Promise<unknown>[]
|
||||
onSessionChange: (
|
||||
agent: BskyAgent,
|
||||
did: string,
|
||||
event: AtpSessionEvent,
|
||||
) => void,
|
||||
) {
|
||||
) => void
|
||||
}) {
|
||||
// There's nothing else left to do, so block on them here.
|
||||
await Promise.all([gates, moderation])
|
||||
await Promise.all(resolvers)
|
||||
|
||||
// Now the agent is ready.
|
||||
const account = agentToSessionAccountOrThrow(this)
|
||||
|
||||
@@ -24,6 +24,11 @@ import {
|
||||
type SessionApiContext,
|
||||
type SessionStateContext,
|
||||
} from '#/state/session/types'
|
||||
import {useOnboardingDispatch} from '#/state/shell/onboarding'
|
||||
import {
|
||||
clearAgeAssuranceData,
|
||||
clearAgeAssuranceDataForDid,
|
||||
} from '#/ageAssurance/data'
|
||||
|
||||
const StateContext = React.createContext<SessionStateContext>({
|
||||
accounts: [],
|
||||
@@ -91,6 +96,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
const cancelPendingTask = useOneTaskAtATime()
|
||||
const [store] = React.useState(() => new SessionStore())
|
||||
const state = React.useSyncExternalStore(store.subscribe, store.getState)
|
||||
const onboardingDispatch = useOnboardingDispatch()
|
||||
|
||||
const onAgentSessionChange = React.useCallback(
|
||||
(agent: BskyAgent, accountDid: string, sessionEvent: AtpSessionEvent) => {
|
||||
@@ -166,6 +172,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
logContext => {
|
||||
addSessionDebugLog({type: 'method:start', method: 'logout'})
|
||||
cancelPendingTask()
|
||||
const prevState = store.getState()
|
||||
store.dispatch({
|
||||
type: 'logged-out-current-account',
|
||||
})
|
||||
@@ -175,8 +182,13 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
{statsig: true},
|
||||
)
|
||||
addSessionDebugLog({type: 'method:end', method: 'logout'})
|
||||
if (prevState.currentAgentState.did) {
|
||||
clearAgeAssuranceDataForDid({did: prevState.currentAgentState.did})
|
||||
}
|
||||
// reset onboarding flow on logout
|
||||
onboardingDispatch({type: 'skip'})
|
||||
},
|
||||
[store, cancelPendingTask],
|
||||
[store, cancelPendingTask, onboardingDispatch],
|
||||
)
|
||||
|
||||
const logoutEveryAccount = React.useCallback<
|
||||
@@ -194,12 +206,15 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
{statsig: true},
|
||||
)
|
||||
addSessionDebugLog({type: 'method:end', method: 'logout'})
|
||||
clearAgeAssuranceData()
|
||||
// reset onboarding flow on logout
|
||||
onboardingDispatch({type: 'skip'})
|
||||
},
|
||||
[store, cancelPendingTask],
|
||||
[store, cancelPendingTask, onboardingDispatch],
|
||||
)
|
||||
|
||||
const resumeSession = React.useCallback<SessionApiContext['resumeSession']>(
|
||||
async storedAccount => {
|
||||
async (storedAccount, isSwitchingAccounts = false) => {
|
||||
addSessionDebugLog({
|
||||
type: 'method:start',
|
||||
method: 'resumeSession',
|
||||
@@ -220,8 +235,12 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
newAccount: account,
|
||||
})
|
||||
addSessionDebugLog({type: 'method:end', method: 'resumeSession', account})
|
||||
if (isSwitchingAccounts) {
|
||||
// reset onboarding flow on switch account
|
||||
onboardingDispatch({type: 'skip'})
|
||||
}
|
||||
},
|
||||
[store, onAgentSessionChange, cancelPendingTask],
|
||||
[store, onAgentSessionChange, cancelPendingTask, onboardingDispatch],
|
||||
)
|
||||
|
||||
const partialRefreshSession = React.useCallback<
|
||||
@@ -254,6 +273,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
accountDid: account.did,
|
||||
})
|
||||
addSessionDebugLog({type: 'method:end', method: 'removeAccount', account})
|
||||
clearAgeAssuranceDataForDid({did: account.did})
|
||||
},
|
||||
[store, cancelPendingTask],
|
||||
)
|
||||
|
||||
@@ -38,7 +38,10 @@ export type SessionApiContext = {
|
||||
logoutEveryAccount: (
|
||||
logContext: LogEvents['account:loggedOut']['logContext'],
|
||||
) => void
|
||||
resumeSession: (account: SessionAccount) => Promise<void>
|
||||
resumeSession: (
|
||||
account: SessionAccount,
|
||||
isSwitchingAccounts?: boolean,
|
||||
) => Promise<void>
|
||||
removeAccount: (account: SessionAccount) => void
|
||||
/**
|
||||
* Calls `getSession` and updates select fields on the current account and
|
||||
|
||||
@@ -2,7 +2,6 @@ import {Provider as ColorModeProvider} from './color-mode'
|
||||
import {Provider as DrawerOpenProvider} from './drawer-open'
|
||||
import {Provider as DrawerSwipableProvider} from './drawer-swipe-disabled'
|
||||
import {Provider as MinimalModeProvider} from './minimal-mode'
|
||||
import {Provider as OnboardingProvider} from './onboarding'
|
||||
import {Provider as ShellLayoutProvder} from './shell-layout'
|
||||
import {Provider as TickEveryMinuteProvider} from './tick-every-minute'
|
||||
|
||||
@@ -23,9 +22,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
<DrawerSwipableProvider>
|
||||
<MinimalModeProvider>
|
||||
<ColorModeProvider>
|
||||
<OnboardingProvider>
|
||||
<TickEveryMinuteProvider>{children}</TickEveryMinuteProvider>
|
||||
</OnboardingProvider>
|
||||
<TickEveryMinuteProvider>{children}</TickEveryMinuteProvider>
|
||||
</ColorModeProvider>
|
||||
</MinimalModeProvider>
|
||||
</DrawerSwipableProvider>
|
||||
|
||||
@@ -81,6 +81,7 @@ export function useUnstablePostSource(key: string) {
|
||||
*/
|
||||
export function buildPostSourceKey(key: string, handle: string) {
|
||||
const urip = new AtUri(key)
|
||||
// @ts-expect-error TODO new-sdk-migration
|
||||
urip.host = handle
|
||||
return urip.toString()
|
||||
}
|
||||
|
||||
+19
-4
@@ -1,4 +1,5 @@
|
||||
import {type ID as PolicyUpdate202508} from '#/components/PolicyUpdateOverlay/updates/202508/config'
|
||||
import {type Geolocation} from '#/geolocation/types'
|
||||
|
||||
/**
|
||||
* Device data that's specific to the device and does not vary based account
|
||||
@@ -25,13 +26,21 @@ export type Device = {
|
||||
regionCode: string | undefined
|
||||
}[]
|
||||
}
|
||||
|
||||
/**
|
||||
* The raw response from the geolocation service, if available. We
|
||||
* cache this here and update it lazily on session start.
|
||||
*/
|
||||
geolocationServiceResponse?: Geolocation
|
||||
/**
|
||||
* The GPS-based geolocation, if the user has granted permission.
|
||||
*/
|
||||
deviceGeolocation?: {
|
||||
countryCode: string | undefined
|
||||
regionCode: string | undefined
|
||||
}
|
||||
deviceGeolocation?: Geolocation
|
||||
/**
|
||||
* The merged geolocation, combining `geolocationServiceResponse` and
|
||||
* `deviceGeolocation`, with preference to `deviceGeolocation`.
|
||||
*/
|
||||
mergedGeolocation?: Geolocation
|
||||
|
||||
trendingBetaEnabled: boolean
|
||||
devMode: boolean
|
||||
@@ -49,4 +58,10 @@ export type Device = {
|
||||
export type Account = {
|
||||
searchTermHistory?: string[]
|
||||
searchAccountHistory?: string[]
|
||||
|
||||
/**
|
||||
* The ISO date string of when this account's birthdate was last updated on
|
||||
* this device.
|
||||
*/
|
||||
birthdateLastUpdatedAt?: string
|
||||
}
|
||||
|
||||
@@ -154,7 +154,7 @@ import {
|
||||
type VideoState,
|
||||
} from './state/video'
|
||||
import {type TextInputRef} from './text-input/TextInput.types'
|
||||
import {getVideoMetadata} from './videos/metadata'
|
||||
import {getVideoMetadata} from './videos/pickVideo'
|
||||
import {clearThumbnailCache} from './videos/VideoTranscodeBackdrop'
|
||||
|
||||
type CancelRef = {
|
||||
|
||||
@@ -18,7 +18,6 @@ import {Button} from '#/components/Button'
|
||||
import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper'
|
||||
import {Image_Stroke2_Corner0_Rounded as ImageIcon} from '#/components/icons/Image'
|
||||
import * as toast from '#/components/Toast'
|
||||
import {hasWebCodecs} from './videos/metadata'
|
||||
|
||||
export type SelectMediaButtonProps = {
|
||||
disabled?: boolean
|
||||
@@ -266,15 +265,9 @@ async function processImagePickerAssets(
|
||||
/*
|
||||
* Filesize appears to be stable across all platforms, so we can use it
|
||||
* to filter out large files on web. On native, we compress these anyway,
|
||||
* so we only check on web. On web, we can reject early if the browser
|
||||
* doesn't support WebCodecs.
|
||||
* so we only check on web.
|
||||
*/
|
||||
if (
|
||||
isWeb &&
|
||||
!hasWebCodecs() &&
|
||||
asset.fileSize &&
|
||||
asset.fileSize > VIDEO_MAX_SIZE
|
||||
) {
|
||||
if (isWeb && asset.fileSize && asset.fileSize > VIDEO_MAX_SIZE) {
|
||||
errors.add(SelectedAssetError.FileTooBig)
|
||||
continue
|
||||
}
|
||||
@@ -290,9 +283,10 @@ async function processImagePickerAssets(
|
||||
if (type === 'gif') {
|
||||
/*
|
||||
* Filesize appears to be stable across all platforms, so we can use it
|
||||
* to filter out large files. We can't compress GIFs on either platform.
|
||||
* to filter out large files on web. On native, we compress GIFs as
|
||||
* videos anyway, so we only check on web.
|
||||
*/
|
||||
if (asset.fileSize && asset.fileSize > VIDEO_MAX_SIZE) {
|
||||
if (isWeb && asset.fileSize && asset.fileSize > VIDEO_MAX_SIZE) {
|
||||
errors.add(SelectedAssetError.FileTooBig)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -7,18 +7,19 @@ import {type CompressedVideo} from '#/lib/media/video/types'
|
||||
import {clamp} from '#/lib/numbers'
|
||||
import {useAutoplayDisabled} from '#/state/preferences'
|
||||
import {ExternalEmbedRemoveBtn} from '#/view/com/composer/ExternalEmbedRemoveBtn'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {atoms as a} from '#/alf'
|
||||
import * as Toast from '#/components/Toast'
|
||||
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
|
||||
|
||||
export function VideoPreview({
|
||||
asset,
|
||||
video,
|
||||
|
||||
clear,
|
||||
}: {
|
||||
asset: ImagePickerAsset
|
||||
video: CompressedVideo
|
||||
isActivePost?: boolean // unused on web
|
||||
|
||||
clear: () => void
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
@@ -62,7 +63,7 @@ export function VideoPreview({
|
||||
playsInline
|
||||
onError={err => {
|
||||
console.error('Error loading video', err)
|
||||
Toast.show(_(msg`Could not process your video`), {type: 'error'})
|
||||
Toast.show(_(msg`Could not process your video`), 'xmark')
|
||||
clear()
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,26 +1,7 @@
|
||||
import {atoms as a, flatten} from '#/alf'
|
||||
|
||||
export function clearThumbnailCache() {
|
||||
// no-op on web
|
||||
// no-op
|
||||
}
|
||||
|
||||
export function VideoTranscodeBackdrop({uri}: {uri: string}) {
|
||||
return (
|
||||
<video
|
||||
src={uri}
|
||||
style={flatten([
|
||||
a.absolute,
|
||||
a.inset_0,
|
||||
a.h_full,
|
||||
a.w_full,
|
||||
{
|
||||
objectFit: 'cover',
|
||||
filter: 'blur(15px)',
|
||||
transform: 'scale(1.1)', // hide blur edges
|
||||
},
|
||||
])}
|
||||
muted
|
||||
playsInline
|
||||
/>
|
||||
)
|
||||
export function VideoTranscodeBackdrop() {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import ProgressPie from 'react-native-progress/Pie'
|
||||
import {type ImagePickerAsset} from 'expo-image-picker'
|
||||
|
||||
import {clamp} from '#/lib/numbers'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {ExternalEmbedRemoveBtn} from '../ExternalEmbedRemoveBtn'
|
||||
import {VideoTranscodeBackdrop} from './VideoTranscodeBackdrop'
|
||||
@@ -19,6 +20,8 @@ export function VideoTranscodeProgress({
|
||||
}) {
|
||||
const t = useTheme()
|
||||
|
||||
if (isWeb) return null
|
||||
|
||||
let aspectRatio = asset.width / asset.height
|
||||
|
||||
if (isNaN(aspectRatio)) {
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import {type ImagePickerAsset} from 'expo-image-picker'
|
||||
|
||||
export const getVideoMetadata = (_file: File): Promise<ImagePickerAsset> => {
|
||||
throw new Error('getVideoMetadata is web only')
|
||||
}
|
||||
|
||||
export function hasWebCodecs(): boolean {
|
||||
return false
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user