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>
This commit is contained in:
Eric Bailey
2025-12-04 15:20:00 -06:00
committed by GitHub
parent 7735183af4
commit c4aef9f668
91 changed files with 3016 additions and 1799 deletions
+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,
])
+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)
}
+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