Compare commits

..

9 Commits

Author SHA1 Message Date
Eric Bailey f4f0a487e7 Update copy 2025-12-04 16:27:22 -06:00
Eric Bailey 3dee9e75f9 Add derived flags, add back disabled adult content for users under 18 2025-12-04 16:18:00 -06:00
Eric Bailey c4aef9f668 Age Assurance V2 (#9479)
* Age Assurance V2

* Tighten up test

* Add todos for sdk migration

* Align RQ versions

* Use useEffect for side effect

* Improve effects, memoize

* Standarize on birthdate

* Copy feedback

* Copilot

* Add support link

* Reove double ..

* Cleanup

* Remove redirect dialog

* Cleanup todos, add comments

* Update splash in main template too

* Mock some stuff

* Exhaustive checks

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

* Exhaustive checks

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

* Small fix to bday handling

* Add comment

* onboarding style tweak

sneaking this in sorry!

* rm unreachable breaks

* Put useIntentHandler back on web

* Remove misleading success set

* Align on birthdate

---------

Co-authored-by: Samuel Newman <mozzius@protonmail.com>
2025-12-04 15:20:00 -06:00
Jim Calabro 7735183af4 Merge pull request #9475 from bluesky-social/jc/cache
Use FromCache Rather Than FromFile on Template Render
2025-12-03 16:47:46 -05:00
pfrazee f0c9e1ea5e Nightly source-language update 2025-12-03 02:41:44 +00:00
Samuel Newman 4d1b1bb2fd Catch crop cancelled errors (#9451) 2025-12-02 10:06:12 +02:00
pfrazee 507531e1db Nightly source-language update 2025-12-02 02:41:53 +00:00
Samuel Newman 0ba4edab8d add dot separated time style (#9455) 2025-12-01 17:52:19 +02:00
pfrazee 1eacf427b5 Nightly source-language update 2025-11-28 02:39:29 +00:00
110 changed files with 3480 additions and 2433 deletions
-3
View File
@@ -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=
+4 -4
View File
@@ -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)
})
+1 -1
View File
@@ -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 {
+12 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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>
)
}
+29
View File
@@ -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>
)
}
+3
View File
@@ -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>
</>
)
}
+495
View File
@@ -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>
)
}
+84
View File
@@ -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
}
+110
View File
@@ -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>
)
}
+100
View File
@@ -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])
}
+53
View File
@@ -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
}
}
+74
View File
@@ -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],
)
}
+88
View File
@@ -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
}
-193
View File
@@ -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 />
</>
)
}
+5 -3
View File
@@ -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 youre 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 wont 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.`,
+70 -38
View File
@@ -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,
+5 -8
View File
@@ -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
+12
View File
@@ -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,
}
+19
View File
@@ -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
}
+144
View File
@@ -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])
}
+65
View File
@@ -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>
)
}
+136
View File
@@ -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
}
+4
View File
@@ -0,0 +1,4 @@
export type Geolocation = {
countryCode: string | undefined
regionCode: string | undefined
}
+113
View File
@@ -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
}
+23
View File
@@ -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')
})
})
+1
View File
@@ -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({
+1
View File
@@ -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
View File
@@ -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)
+1 -1
View File
@@ -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 {
+1
View File
@@ -9,6 +9,7 @@ export const ZENDESK_SUPPORT_URL =
export enum SupportCode {
AA_DID = 'AA_DID',
AA_BIRTHDATE = 'AA_BIRTHDATE',
}
/**
+4 -35
View File
@@ -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,
])
+29 -289
View File
@@ -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})
}
+12 -15
View File
@@ -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() {
+10
View File
@@ -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)
}
+12
View File
@@ -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
View File
@@ -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',
+2 -1
View File
@@ -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
File diff suppressed because it is too large Load Diff
+1
View File
@@ -22,6 +22,7 @@ export type MetricEvents = {
| 'SignupQueued'
| 'Deactivated'
| 'Takendown'
| 'AgeAssuranceNoAccessScreen'
scope: 'current' | 'every'
}
'notifications:openApp': {
+1 -1
View File
@@ -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
View File
@@ -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={[
+13 -5
View File
@@ -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>
)}
+1
View File
@@ -0,0 +1 @@
export const snoozeBirthdateUpdateAllowedForDid = () => {}
-11
View File
@@ -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,
})
-156
View File
@@ -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)
}
-33
View File
@@ -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']
}
-44
View File
@@ -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])
}
+64
View File
@@ -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!)
},
})
}
+2 -1
View File
@@ -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
}
-141
View File
@@ -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`)
}
}
}
-30
View File
@@ -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,
}
-19
View File
@@ -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)
}
}
-155
View File
@@ -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)
}
-9
View File
@@ -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
}
-180
View File
@@ -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,
}
}
}
+1 -6
View File
@@ -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(
+2
View File
@@ -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
}
+1
View File
@@ -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
}
+9 -24
View File
@@ -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()
+1
View File
@@ -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,
+1
View File
@@ -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
View File
@@ -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 -4
View File
@@ -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],
)
+4 -1
View File
@@ -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
+1 -4
View File
@@ -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>
+1
View File
@@ -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
View File
@@ -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
}
+1 -1
View File
@@ -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 = {
+5 -11
View File
@@ -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)) {
-9
View File
@@ -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