Bluesky app fork with some witchin' additions 💫

UI for age assurance compliance (#8652)

* Add geo prop

* Add prelim fetch

* Add geo debug

* Pass in assurance state to notifications registration

* Comments

* Bump git index

* Add some component utils, no design, gate chat

* Disable mod prefs buttons, does not yet edit mod prefs

* Add initial prompt component

* Refine logic for showing prompt

* Add send email dialog

* Hook up dialog to fake mutation

* Fix geo debug bug

* Move provider inside query provider

* Slightly better screen gater

* Ok decent fallback with isExempt

* Reorg

* Wrap prompt in new logic

* Override mod prefs

* Use real endpoints, optimistic state

* Add persistent card, add time-ago, warning to dialog

* Add comment

* No undefined query values

* Fix case in import

* Wait for AA to load before registering push

* Override prefs in all locations

* Small refactor of notifications registration

* Register push after aa state

* Add retries

* Update blocked screens UI

* Strengthen email validation

* Add intent dialog

* Do service auth for init

* Rug refreshJwt

* Update copy

* Some mobile styles, add dev mode option

* Fix links on native

* Clean up intent dialog on native

* Don't mutate existing session, only copy

* Handle email validation error from server

* Clarity is better

* Moar clear

* Fixes

* Tweaks

* Add country code

* Gate it

* Refresh state after redirect

* Re-check on window focus

* Remove todo

* Enable in dev

* Check for did match on redirect

* Add blocked state

* Add appeal dialog

* Copy tweaks

* Inset in blue well

* Nux the prompt

* Copy updates

* Refetch just in case

* Uppercase country code

* Align copy, add notice to chat screens

* Tweak copy

* Add test code

* Add debug code

* Refactor AccountCard

* Big refactor

* Delay post-feed queries instead

* Debug code

* Clean up state

* Reorg

* Clean up copy

* Comments

* Reorg

* UPdate URL

* Cleanup

* Remove todo

* Update debug code

* revert unneeded changes

* UPdate nux name

* Revert unneeded change

* Updaet storage schema

* Checkpoint: cleanup

* Checkpoint: almost there

* isLoaded -> isReady

* Rename useAgeAssurance

* isUnderage -> isDeclaredUnderage

* Decompose, add docblocks

* Refactor

* UPdate debug

* Apply suggestion from @surfdude29

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Apply suggestion from @surfdude29

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Apply suggestion from @surfdude29

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Apply suggestion from @surfdude29

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Apply suggestion from @surfdude29

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Apply suggestion from @surfdude29

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Drop including Bluesky

* Apply suggestion from @surfdude29

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Apply suggestion from @surfdude29

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Remove todo

* Gate debug

* Revert unneeded change

* Fail closed

* Comments

* Comment

* Comment

* fix prettier

* rm viewheader

* bump sdk

* prevent overlap in admonition

* add age assurance intent route

* Just meow

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

* Nix callback

* Fix spelling of dismissible lol

* Don't compare translated string

* Better KWS link labels

* Hide DMs send options in menu

* Add button

* Fix order

* Use only supported languages

* Rm button

* best-effort language mapping

* improve typing

---------

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>
Co-authored-by: Samuel Newman <mozzius@protonmail.com>

authored by

Eric Bailey
surfdude29
Samuel Newman
and committed by
GitHub
1dbc3313 712c3ad4

+2204 -263
+1
.eslintrc.js
··· 34 34 'P', 35 35 'Admonition', 36 36 'Admonition.Admonition', 37 + 'AgeAssuranceAdmonition', 37 38 'Span', 38 39 ], 39 40 impliedTextProps: [],
+1
bskyweb/cmd/bskyweb/server.go
··· 302 302 e.GET("/support/copyright", server.WebGeneric) 303 303 e.GET("/intent/compose", server.WebGeneric) 304 304 e.GET("/intent/verify-email", server.WebGeneric) 305 + e.GET("/intent/age-assurance", server.WebGeneric) 305 306 e.GET("/messages", server.WebGeneric) 306 307 e.GET("/messages/:conversation", server.WebGeneric) 307 308
+2 -2
package.json
··· 69 69 "icons:optimize": "svgo -f ./assets/icons" 70 70 }, 71 71 "dependencies": { 72 - "@atproto/api": "^0.15.21", 72 + "@atproto/api": "^0.15.26", 73 73 "@bitdrift/react-native": "^0.6.8", 74 74 "@braintree/sanitize-url": "^6.0.2", 75 75 "@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet", ··· 218 218 "zod": "^3.20.2" 219 219 }, 220 220 "devDependencies": { 221 - "@atproto/dev-env": "^0.3.150", 221 + "@atproto/dev-env": "^0.3.155", 222 222 "@babel/core": "^7.26.0", 223 223 "@babel/preset-env": "^7.26.0", 224 224 "@babel/runtime": "^7.26.0",
+44 -42
src/App.native.tsx
··· 26 26 import {logger} from '#/logger' 27 27 import {isAndroid, isIOS} from '#/platform/detection' 28 28 import {Provider as A11yProvider} from '#/state/a11y' 29 + import {Provider as AgeAssuranceProvider} from '#/state/ageAssurance' 29 30 import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes' 30 31 import {Provider as DialogStateProvider} from '#/state/dialogs' 31 32 import {listenSessionDropped} from '#/state/events' ··· 95 96 const {resumeSession} = useSessionApi() 96 97 const theme = useColorModeTheme() 97 98 const {_} = useLingui() 98 - 99 99 const hasCheckedReferrer = useStarterPackEntry() 100 100 101 101 // init ··· 137 137 // Resets the entire tree below when it changes: 138 138 key={currentAccount?.did}> 139 139 <QueryProvider currentDid={currentAccount?.did}> 140 - <ComposerProvider> 141 - <StatsigProvider> 142 - <MessagesProvider> 143 - {/* LabelDefsProvider MUST come before ModerationOptsProvider */} 144 - <LabelDefsProvider> 145 - <ModerationOptsProvider> 146 - <LoggedOutViewProvider> 147 - <SelectedFeedProvider> 148 - <HiddenRepliesProvider> 149 - <HomeBadgeProvider> 150 - <UnreadNotifsProvider> 151 - <BackgroundNotificationPreferencesProvider> 152 - <MutedThreadsProvider> 153 - <ProgressGuideProvider> 154 - <ServiceAccountManager> 155 - <HideBottomBarBorderProvider> 156 - <GestureHandlerRootView 157 - style={s.h100pct}> 158 - <GlobalGestureEventsProvider> 159 - <IntentDialogProvider> 160 - <TestCtrls /> 161 - <Shell /> 162 - <NuxDialogs /> 163 - </IntentDialogProvider> 164 - </GlobalGestureEventsProvider> 165 - </GestureHandlerRootView> 166 - </HideBottomBarBorderProvider> 167 - </ServiceAccountManager> 168 - </ProgressGuideProvider> 169 - </MutedThreadsProvider> 170 - </BackgroundNotificationPreferencesProvider> 171 - </UnreadNotifsProvider> 172 - </HomeBadgeProvider> 173 - </HiddenRepliesProvider> 174 - </SelectedFeedProvider> 175 - </LoggedOutViewProvider> 176 - </ModerationOptsProvider> 177 - </LabelDefsProvider> 178 - </MessagesProvider> 179 - </StatsigProvider> 180 - </ComposerProvider> 140 + <StatsigProvider> 141 + <AgeAssuranceProvider> 142 + <ComposerProvider> 143 + <MessagesProvider> 144 + {/* LabelDefsProvider MUST come before ModerationOptsProvider */} 145 + <LabelDefsProvider> 146 + <ModerationOptsProvider> 147 + <LoggedOutViewProvider> 148 + <SelectedFeedProvider> 149 + <HiddenRepliesProvider> 150 + <HomeBadgeProvider> 151 + <UnreadNotifsProvider> 152 + <BackgroundNotificationPreferencesProvider> 153 + <MutedThreadsProvider> 154 + <ProgressGuideProvider> 155 + <ServiceAccountManager> 156 + <HideBottomBarBorderProvider> 157 + <GestureHandlerRootView 158 + style={s.h100pct}> 159 + <GlobalGestureEventsProvider> 160 + <IntentDialogProvider> 161 + <TestCtrls /> 162 + <Shell /> 163 + <NuxDialogs /> 164 + </IntentDialogProvider> 165 + </GlobalGestureEventsProvider> 166 + </GestureHandlerRootView> 167 + </HideBottomBarBorderProvider> 168 + </ServiceAccountManager> 169 + </ProgressGuideProvider> 170 + </MutedThreadsProvider> 171 + </BackgroundNotificationPreferencesProvider> 172 + </UnreadNotifsProvider> 173 + </HomeBadgeProvider> 174 + </HiddenRepliesProvider> 175 + </SelectedFeedProvider> 176 + </LoggedOutViewProvider> 177 + </ModerationOptsProvider> 178 + </LabelDefsProvider> 179 + </MessagesProvider> 180 + </ComposerProvider> 181 + </AgeAssuranceProvider> 182 + </StatsigProvider> 181 183 </QueryProvider> 182 184 </React.Fragment> 183 185 </VideoVolumeProvider>
+40 -37
src/App.web.tsx
··· 15 15 import I18nProvider from '#/locale/i18nProvider' 16 16 import {logger} from '#/logger' 17 17 import {Provider as A11yProvider} from '#/state/a11y' 18 + import {Provider as AgeAssuranceProvider} from '#/state/ageAssurance' 18 19 import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes' 19 20 import {Provider as DialogStateProvider} from '#/state/dialogs' 20 21 import {listenSessionDropped} from '#/state/events' ··· 116 117 // Resets the entire tree below when it changes: 117 118 key={currentAccount?.did}> 118 119 <QueryProvider currentDid={currentAccount?.did}> 119 - <ComposerProvider> 120 - <StatsigProvider> 121 - <MessagesProvider> 122 - {/* LabelDefsProvider MUST come before ModerationOptsProvider */} 123 - <LabelDefsProvider> 124 - <ModerationOptsProvider> 125 - <LoggedOutViewProvider> 126 - <SelectedFeedProvider> 127 - <HiddenRepliesProvider> 128 - <HomeBadgeProvider> 129 - <UnreadNotifsProvider> 130 - <BackgroundNotificationPreferencesProvider> 131 - <MutedThreadsProvider> 132 - <SafeAreaProvider> 133 - <ProgressGuideProvider> 134 - <ServiceConfigProvider> 135 - <HideBottomBarBorderProvider> 136 - <IntentDialogProvider> 137 - <Shell /> 138 - <NuxDialogs /> 139 - </IntentDialogProvider> 140 - </HideBottomBarBorderProvider> 141 - </ServiceConfigProvider> 142 - </ProgressGuideProvider> 143 - </SafeAreaProvider> 144 - </MutedThreadsProvider> 145 - </BackgroundNotificationPreferencesProvider> 146 - </UnreadNotifsProvider> 147 - </HomeBadgeProvider> 148 - </HiddenRepliesProvider> 149 - </SelectedFeedProvider> 150 - </LoggedOutViewProvider> 151 - </ModerationOptsProvider> 152 - </LabelDefsProvider> 153 - </MessagesProvider> 154 - </StatsigProvider> 155 - </ComposerProvider> 120 + <StatsigProvider> 121 + <AgeAssuranceProvider> 122 + <ComposerProvider> 123 + <MessagesProvider> 124 + {/* LabelDefsProvider MUST come before ModerationOptsProvider */} 125 + <LabelDefsProvider> 126 + <ModerationOptsProvider> 127 + <LoggedOutViewProvider> 128 + <SelectedFeedProvider> 129 + <HiddenRepliesProvider> 130 + <HomeBadgeProvider> 131 + <UnreadNotifsProvider> 132 + <BackgroundNotificationPreferencesProvider> 133 + <MutedThreadsProvider> 134 + <SafeAreaProvider> 135 + <ProgressGuideProvider> 136 + <ServiceConfigProvider> 137 + <HideBottomBarBorderProvider> 138 + <IntentDialogProvider> 139 + <Shell /> 140 + <NuxDialogs /> 141 + </IntentDialogProvider> 142 + </HideBottomBarBorderProvider> 143 + </ServiceConfigProvider> 144 + </ProgressGuideProvider> 145 + </SafeAreaProvider> 146 + </MutedThreadsProvider> 147 + </BackgroundNotificationPreferencesProvider> 148 + </UnreadNotifsProvider> 149 + </HomeBadgeProvider> 150 + </HiddenRepliesProvider> 151 + </SelectedFeedProvider> 152 + </LoggedOutViewProvider> 153 + </ModerationOptsProvider> 154 + </LabelDefsProvider> 155 + </MessagesProvider> 156 + </ComposerProvider> 157 + </AgeAssuranceProvider> 158 + </StatsigProvider> 156 159 </QueryProvider> 157 160 <ToastContainer /> 158 161 </React.Fragment>
+50
src/components/LanguageSelect.tsx
··· 1 + import React from 'react' 2 + import {msg} from '@lingui/macro' 3 + import {useLingui} from '@lingui/react' 4 + 5 + import {sanitizeAppLanguageSetting} from '#/locale/helpers' 6 + import {APP_LANGUAGES} from '#/locale/languages' 7 + import * as Select from '#/components/Select' 8 + 9 + export function LanguageSelect({ 10 + value, 11 + onChange, 12 + items = APP_LANGUAGES.map(l => ({ 13 + label: l.name, 14 + value: l.code2, 15 + })), 16 + }: { 17 + value?: string 18 + onChange: (value: string) => void 19 + items?: {label: string; value: string}[] 20 + }) { 21 + const {_} = useLingui() 22 + 23 + const handleOnChange = React.useCallback( 24 + (value: string) => { 25 + if (!value) return 26 + onChange(sanitizeAppLanguageSetting(value)) 27 + }, 28 + [onChange], 29 + ) 30 + 31 + return ( 32 + <Select.Root 33 + value={value ? sanitizeAppLanguageSetting(value) : undefined} 34 + onValueChange={handleOnChange}> 35 + <Select.Trigger label={_(msg`Select language`)}> 36 + <Select.ValueText placeholder={_(msg`Select language`)} /> 37 + <Select.Icon /> 38 + </Select.Trigger> 39 + <Select.Content 40 + renderItem={({label, value}) => ( 41 + <Select.Item value={value} label={label}> 42 + <Select.ItemIndicator /> 43 + <Select.ItemText>{label}</Select.ItemText> 44 + </Select.Item> 45 + )} 46 + items={items} 47 + /> 48 + </Select.Root> 49 + ) 50 + }
+3 -1
src/components/PostControls/ShareMenu/ShareMenuItems.tsx
··· 11 11 import {toShareUrl} from '#/lib/strings/url-helpers' 12 12 import {logger} from '#/logger' 13 13 import {isIOS} from '#/platform/detection' 14 + import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance' 14 15 import {useProfileShadow} from '#/state/cache/profile-shadow' 15 16 import {useSession} from '#/state/session' 16 17 import * as Toast from '#/view/com/util/Toast' ··· 36 37 const navigation = useNavigation<NavigationProp>() 37 38 const sendViaChatControl = useDialogControl() 38 39 const [devModeEnabled] = useDevMode() 40 + const {isAgeRestricted} = useAgeAssurance() 39 41 40 42 const postUri = post.uri 41 43 const postAuthor = useProfileShadow(post.author) ··· 89 91 return ( 90 92 <> 91 93 <Menu.Outer> 92 - {hasSession && ( 94 + {hasSession && !isAgeRestricted && ( 93 95 <Menu.Group> 94 96 <Menu.ContainerItem> 95 97 <RecentChats postUri={postUri} />
+3 -1
src/components/PostControls/ShareMenu/ShareMenuItems.web.tsx
··· 11 11 import {toShareUrl} from '#/lib/strings/url-helpers' 12 12 import {logger} from '#/logger' 13 13 import {isWeb} from '#/platform/detection' 14 + import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance' 14 15 import {useProfileShadow} from '#/state/cache/profile-shadow' 15 16 import {useSession} from '#/state/session' 16 17 import {useBreakpoints} from '#/alf' ··· 38 39 const embedPostControl = useDialogControl() 39 40 const sendViaChatControl = useDialogControl() 40 41 const [devModeEnabled] = useDevMode() 42 + const {isAgeRestricted} = useAgeAssurance() 41 43 42 44 const postUri = post.uri 43 45 const postCid = post.cid ··· 96 98 <Menu.Outer> 97 99 {!hideInPWI && copyLinkItem} 98 100 99 - {hasSession && ( 101 + {hasSession && !isAgeRestricted && ( 100 102 <Menu.Item 101 103 testID="postDropdownSendViaDMBtn" 102 104 label={_(msg`Send via direct message`)}
+148
src/components/ageAssurance/AgeAssuranceAccountCard.tsx
··· 1 + import {View} from 'react-native' 2 + import {msg, Trans} from '@lingui/macro' 3 + import {useLingui} from '@lingui/react' 4 + 5 + import {dateDiff, useGetTimeAgo} from '#/lib/hooks/useTimeAgo' 6 + import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance' 7 + import {atoms as a, useBreakpoints, useTheme, type ViewStyleProp} from '#/alf' 8 + import {Admonition} from '#/components/Admonition' 9 + import {AgeAssuranceAppealDialog} from '#/components/ageAssurance/AgeAssuranceAppealDialog' 10 + import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge' 11 + import { 12 + AgeAssuranceInitDialog, 13 + useDialogControl, 14 + } from '#/components/ageAssurance/AgeAssuranceInitDialog' 15 + import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy' 16 + import {Button, ButtonText} from '#/components/Button' 17 + import * as Dialog from '#/components/Dialog' 18 + import {Divider} from '#/components/Divider' 19 + import {createStaticClick, InlineLinkText} from '#/components/Link' 20 + import {Text} from '#/components/Typography' 21 + 22 + export function AgeAssuranceAccountCard({style}: ViewStyleProp & {}) { 23 + const {isReady, isAgeRestricted, isDeclaredUnderage} = useAgeAssurance() 24 + 25 + if (!isReady) return null 26 + if (isDeclaredUnderage) return null 27 + if (!isAgeRestricted) return null 28 + 29 + return <Inner style={style} /> 30 + } 31 + 32 + function Inner({style}: ViewStyleProp & {}) { 33 + const t = useTheme() 34 + const {_, i18n} = useLingui() 35 + const control = useDialogControl() 36 + const appealControl = Dialog.useDialogControl() 37 + const getTimeAgo = useGetTimeAgo() 38 + const {gtPhone} = useBreakpoints() 39 + 40 + const copy = useAgeAssuranceCopy() 41 + const {status, lastInitiatedAt} = useAgeAssurance() 42 + const isBlocked = status === 'blocked' 43 + const hasInitiated = !!lastInitiatedAt 44 + const timeAgo = lastInitiatedAt 45 + ? getTimeAgo(lastInitiatedAt, new Date()) 46 + : null 47 + const diff = lastInitiatedAt 48 + ? dateDiff(lastInitiatedAt, new Date(), 'down') 49 + : null 50 + 51 + return ( 52 + <> 53 + <AgeAssuranceInitDialog control={control} /> 54 + <AgeAssuranceAppealDialog control={appealControl} /> 55 + 56 + <View style={style}> 57 + <View 58 + style={[a.p_lg, a.rounded_md, a.border, t.atoms.border_contrast_low]}> 59 + <View 60 + style={[ 61 + a.flex_row, 62 + a.justify_between, 63 + a.align_center, 64 + a.gap_lg, 65 + a.pb_md, 66 + a.z_10, 67 + ]}> 68 + <View style={[a.align_start]}> 69 + <AgeAssuranceBadge /> 70 + </View> 71 + </View> 72 + 73 + <View style={[a.pb_md]}> 74 + <Text style={[a.text_sm, a.leading_snug]}>{copy.notice}</Text> 75 + </View> 76 + 77 + {isBlocked ? ( 78 + <Admonition type="warning"> 79 + <Trans> 80 + You are currently unable to access Bluesky's Age Assurance flow. 81 + Please{' '} 82 + <InlineLinkText 83 + label={_(msg`Contact our moderation team`)} 84 + {...createStaticClick(() => { 85 + appealControl.open() 86 + })}> 87 + contact our moderation team 88 + </InlineLinkText>{' '} 89 + if you believe this is an error. 90 + </Trans> 91 + </Admonition> 92 + ) : ( 93 + <> 94 + <Divider /> 95 + <View 96 + style={[ 97 + a.pt_md, 98 + gtPhone 99 + ? [ 100 + a.flex_row_reverse, 101 + a.gap_xl, 102 + a.justify_between, 103 + a.align_center, 104 + ] 105 + : [a.gap_md], 106 + ]}> 107 + <Button 108 + label={_(msg`Verify now`)} 109 + size="small" 110 + variant="solid" 111 + color={hasInitiated ? 'secondary' : 'primary'} 112 + onPress={() => control.open()}> 113 + <ButtonText> 114 + {hasInitiated ? ( 115 + <Trans>Verify again</Trans> 116 + ) : ( 117 + <Trans>Verify now</Trans> 118 + )} 119 + </ButtonText> 120 + </Button> 121 + 122 + {lastInitiatedAt && timeAgo && diff ? ( 123 + <Text 124 + style={[a.text_sm, a.italic, t.atoms.text_contrast_medium]} 125 + title={i18n.date(lastInitiatedAt, { 126 + dateStyle: 'medium', 127 + timeStyle: 'medium', 128 + })}> 129 + {diff.value === 0 ? ( 130 + <Trans>Last initiated just now</Trans> 131 + ) : ( 132 + <Trans>Last initiated {timeAgo} ago</Trans> 133 + )} 134 + </Text> 135 + ) : ( 136 + <Text 137 + style={[a.text_sm, a.italic, t.atoms.text_contrast_medium]}> 138 + <Trans>Age assurance only takes a few minutes</Trans> 139 + </Text> 140 + )} 141 + </View> 142 + </> 143 + )} 144 + </View> 145 + </View> 146 + </> 147 + ) 148 + }
+100
src/components/ageAssurance/AgeAssuranceAdmonition.tsx
··· 1 + import {View} from 'react-native' 2 + import {msg, Trans} from '@lingui/macro' 3 + import {useLingui} from '@lingui/react' 4 + 5 + import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance' 6 + import {atoms as a, select, useTheme, type ViewStyleProp} from '#/alf' 7 + import {useDialogControl} from '#/components/ageAssurance/AgeAssuranceInitDialog' 8 + import type * as Dialog from '#/components/Dialog' 9 + import {ShieldCheck_Stroke2_Corner0_Rounded as Shield} from '#/components/icons/Shield' 10 + import {InlineLinkText} from '#/components/Link' 11 + import {Text} from '#/components/Typography' 12 + 13 + export function AgeAssuranceAdmonition({ 14 + children, 15 + style, 16 + }: ViewStyleProp & {children: React.ReactNode}) { 17 + const control = useDialogControl() 18 + const {isReady, isDeclaredUnderage, isAgeRestricted} = useAgeAssurance() 19 + 20 + if (!isReady) return null 21 + if (isDeclaredUnderage) return null 22 + if (!isAgeRestricted) return null 23 + 24 + return ( 25 + <Inner style={style} control={control}> 26 + {children} 27 + </Inner> 28 + ) 29 + } 30 + 31 + function Inner({ 32 + children, 33 + style, 34 + }: ViewStyleProp & { 35 + children: React.ReactNode 36 + control: Dialog.DialogControlProps 37 + }) { 38 + const t = useTheme() 39 + const {_} = useLingui() 40 + 41 + return ( 42 + <> 43 + <View style={style}> 44 + <View 45 + style={[ 46 + a.p_md, 47 + a.rounded_md, 48 + a.border, 49 + a.flex_row, 50 + a.align_start, 51 + a.gap_sm, 52 + { 53 + backgroundColor: select(t.name, { 54 + light: t.palette.primary_25, 55 + dark: t.palette.primary_25, 56 + dim: t.palette.primary_25, 57 + }), 58 + borderColor: select(t.name, { 59 + light: t.palette.primary_100, 60 + dark: t.palette.primary_100, 61 + dim: t.palette.primary_100, 62 + }), 63 + }, 64 + ]}> 65 + <View 66 + style={[ 67 + a.align_center, 68 + a.justify_center, 69 + a.rounded_full, 70 + { 71 + width: 32, 72 + height: 32, 73 + backgroundColor: select(t.name, { 74 + light: t.palette.primary_100, 75 + dark: t.palette.primary_100, 76 + dim: t.palette.primary_100, 77 + }), 78 + }, 79 + ]}> 80 + <Shield size="md" /> 81 + </View> 82 + <View style={[a.flex_1, a.gap_xs, a.pr_2xl]}> 83 + <Text style={[a.text_sm, a.leading_snug]}>{children}</Text> 84 + <Text style={[a.text_sm, a.leading_snug, a.font_bold]}> 85 + <Trans> 86 + Learn more in your{' '} 87 + <InlineLinkText 88 + label={_(msg`Go to account settings`)} 89 + to={'/settings/account'} 90 + style={[a.text_sm, a.leading_snug, a.font_bold]}> 91 + account settings. 92 + </InlineLinkText> 93 + </Trans> 94 + </Text> 95 + </View> 96 + </View> 97 + </View> 98 + </> 99 + ) 100 + }
+140
src/components/ageAssurance/AgeAssuranceAppealDialog.tsx
··· 1 + import React from 'react' 2 + import {View} from 'react-native' 3 + import {BSKY_LABELER_DID, ComAtprotoModerationDefs} from '@atproto/api' 4 + import {msg, Trans} from '@lingui/macro' 5 + import {useLingui} from '@lingui/react' 6 + import {useMutation} from '@tanstack/react-query' 7 + 8 + import {logger} from '#/logger' 9 + import {useAgent, useSession} from '#/state/session' 10 + import * as Toast from '#/view/com/util/Toast' 11 + import {atoms as a, useBreakpoints, web} from '#/alf' 12 + import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge' 13 + import {Button, ButtonIcon, ButtonText} from '#/components/Button' 14 + import * as Dialog from '#/components/Dialog' 15 + import {Loader} from '#/components/Loader' 16 + import {Text} from '#/components/Typography' 17 + 18 + export function AgeAssuranceAppealDialog({ 19 + control, 20 + }: { 21 + control: Dialog.DialogControlProps 22 + }) { 23 + const {_} = useLingui() 24 + return ( 25 + <Dialog.Outer control={control}> 26 + <Dialog.Handle /> 27 + <Dialog.ScrollableInner 28 + label={_(msg`Contact our moderation team`)} 29 + style={[web({maxWidth: 400})]}> 30 + <Inner control={control} /> 31 + <Dialog.Close /> 32 + </Dialog.ScrollableInner> 33 + </Dialog.Outer> 34 + ) 35 + } 36 + 37 + function Inner({control}: {control: Dialog.DialogControlProps}) { 38 + const {_} = useLingui() 39 + const {currentAccount} = useSession() 40 + const {gtPhone} = useBreakpoints() 41 + const agent = useAgent() 42 + 43 + const [details, setDetails] = React.useState('') 44 + const isInvalid = details.length > 1000 45 + 46 + const {mutate, isPending} = useMutation({ 47 + mutationFn: async () => { 48 + await agent.createModerationReport( 49 + { 50 + reasonType: ComAtprotoModerationDefs.REASONAPPEAL, 51 + subject: { 52 + $type: 'com.atproto.admin.defs#repoRef', 53 + did: currentAccount?.did, 54 + }, 55 + reason: `AGE_ASSURANCE_INQUIRY: ` + details, 56 + }, 57 + { 58 + encoding: 'application/json', 59 + headers: { 60 + 'atproto-proxy': `${BSKY_LABELER_DID}#atproto_labeler`, 61 + }, 62 + }, 63 + ) 64 + }, 65 + onError: err => { 66 + logger.error('AgeAssuranceAppealDialog failed', {safeMessage: err}) 67 + Toast.show( 68 + _(msg`Age assurance inquiry failed to send, please try again.`), 69 + 'xmark', 70 + ) 71 + }, 72 + onSuccess: () => { 73 + control.close() 74 + Toast.show( 75 + _( 76 + msg({ 77 + message: 'Age assurance inquiry was submitted', 78 + context: 'toast', 79 + }), 80 + ), 81 + ) 82 + }, 83 + }) 84 + 85 + return ( 86 + <View> 87 + <View style={[a.align_start]}> 88 + <AgeAssuranceBadge /> 89 + </View> 90 + 91 + <Text style={[a.text_2xl, a.font_heavy, a.pt_md, a.leading_tight]}> 92 + <Trans>Contact us</Trans> 93 + </Text> 94 + 95 + <Text style={[a.text_sm, a.pt_sm, a.leading_snug]}> 96 + <Trans> 97 + Please provide any additional details you feel moderators may need in 98 + order to properly assess your Age Assurance status. 99 + </Trans> 100 + </Text> 101 + 102 + <View style={[a.pt_md]}> 103 + <Dialog.Input 104 + multiline 105 + isInvalid={isInvalid} 106 + value={details} 107 + onChangeText={details => { 108 + setDetails(details) 109 + }} 110 + label={_(msg`Additional details (limit 1000 characters)`)} 111 + numberOfLines={4} 112 + onSubmitEditing={() => mutate()} 113 + /> 114 + <View style={[a.pt_md, a.gap_sm, gtPhone && [a.flex_row_reverse]]}> 115 + <Button 116 + label={_(msg`Submit`)} 117 + size="small" 118 + variant="solid" 119 + color="primary" 120 + onPress={() => mutate()}> 121 + <ButtonText> 122 + <Trans>Submit</Trans> 123 + </ButtonText> 124 + {isPending && <ButtonIcon icon={Loader} position="right" />} 125 + </Button> 126 + <Button 127 + label={_(msg`Cancel`)} 128 + size="small" 129 + variant="solid" 130 + color="secondary" 131 + onPress={() => control.close()}> 132 + <ButtonText> 133 + <Trans>Cancel</Trans> 134 + </ButtonText> 135 + </Button> 136 + </View> 137 + </View> 138 + </View> 139 + ) 140 + }
+46
src/components/ageAssurance/AgeAssuranceBadge.tsx
··· 1 + import {View} from 'react-native' 2 + import {Trans} from '@lingui/macro' 3 + 4 + import {atoms as a, select, useTheme} from '#/alf' 5 + import {ShieldCheck_Stroke2_Corner0_Rounded as Shield} from '#/components/icons/Shield' 6 + import {Text} from '#/components/Typography' 7 + 8 + export function AgeAssuranceBadge() { 9 + const t = useTheme() 10 + 11 + return ( 12 + <View 13 + style={[ 14 + a.flex_row, 15 + a.align_center, 16 + a.gap_xs, 17 + a.px_sm, 18 + a.py_xs, 19 + a.pr_sm, 20 + a.rounded_full, 21 + { 22 + backgroundColor: select(t.name, { 23 + light: t.palette.primary_100, 24 + dark: t.palette.primary_100, 25 + dim: t.palette.primary_100, 26 + }), 27 + }, 28 + ]}> 29 + <Shield size="sm" /> 30 + <Text 31 + style={[ 32 + a.font_bold, 33 + a.leading_snug, 34 + { 35 + color: select(t.name, { 36 + light: t.palette.primary_800, 37 + dark: t.palette.primary_800, 38 + dim: t.palette.primary_800, 39 + }), 40 + }, 41 + ]}> 42 + <Trans>Age Assurance</Trans> 43 + </Text> 44 + </View> 45 + ) 46 + }
+95
src/components/ageAssurance/AgeAssuranceDismissibleHeaderButton.tsx
··· 1 + import {useMemo} from 'react' 2 + import {View} from 'react-native' 3 + import {msg, Trans} from '@lingui/macro' 4 + import {useLingui} from '@lingui/react' 5 + 6 + import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance' 7 + import {Nux, useNux, useSaveNux} from '#/state/queries/nuxs' 8 + import {atoms as a, select, useTheme} from '#/alf' 9 + import {ShieldCheck_Stroke2_Corner0_Rounded as Shield} from '#/components/icons/Shield' 10 + import {Link} from '#/components/Link' 11 + import {Text} from '#/components/Typography' 12 + 13 + export function useInternalState() { 14 + const {isReady, isDeclaredUnderage, isAgeRestricted, lastInitiatedAt} = 15 + useAgeAssurance() 16 + const {nux} = useNux(Nux.AgeAssuranceDismissibleHeaderButton) 17 + const {mutate: save, variables} = useSaveNux() 18 + const hidden = !!variables 19 + 20 + const visible = useMemo(() => { 21 + if (!isReady) return false 22 + if (isDeclaredUnderage) return false 23 + if (!isAgeRestricted) return false 24 + if (lastInitiatedAt) return false 25 + if (hidden) return false 26 + if (nux && nux.completed) return false 27 + return true 28 + }, [ 29 + isReady, 30 + isDeclaredUnderage, 31 + isAgeRestricted, 32 + lastInitiatedAt, 33 + hidden, 34 + nux, 35 + ]) 36 + 37 + const close = () => { 38 + save({ 39 + id: Nux.AgeAssuranceDismissibleHeaderButton, 40 + completed: true, 41 + data: undefined, 42 + }) 43 + } 44 + 45 + return {visible, close} 46 + } 47 + 48 + export function AgeAssuranceDismissibleHeaderButton() { 49 + const t = useTheme() 50 + const {_} = useLingui() 51 + const {visible, close} = useInternalState() 52 + 53 + if (!visible) return null 54 + 55 + return ( 56 + <Link 57 + label={_(msg`Learn more about age assurance`)} 58 + to="/settings/account" 59 + onPress={close}> 60 + <View 61 + style={[ 62 + a.flex_row, 63 + a.align_center, 64 + a.gap_xs, 65 + a.px_sm, 66 + a.pr_sm, 67 + a.rounded_full, 68 + { 69 + paddingVertical: 6, 70 + backgroundColor: select(t.name, { 71 + light: t.palette.primary_100, 72 + dark: t.palette.primary_100, 73 + dim: t.palette.primary_100, 74 + }), 75 + }, 76 + ]}> 77 + <Shield size="sm" /> 78 + <Text 79 + style={[ 80 + a.font_bold, 81 + a.leading_snug, 82 + { 83 + color: select(t.name, { 84 + light: t.palette.primary_800, 85 + dark: t.palette.primary_800, 86 + dim: t.palette.primary_800, 87 + }), 88 + }, 89 + ]}> 90 + <Trans>Age Assurance</Trans> 91 + </Text> 92 + </View> 93 + </Link> 94 + ) 95 + }
+59
src/components/ageAssurance/AgeAssuranceDismissibleNotice.tsx
··· 1 + import {View} from 'react-native' 2 + import {msg} from '@lingui/macro' 3 + import {useLingui} from '@lingui/react' 4 + 5 + import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance' 6 + import {Nux, useNux, useSaveNux} from '#/state/queries/nuxs' 7 + import {atoms as a, type ViewStyleProp} from '#/alf' 8 + import {AgeAssuranceAdmonition} from '#/components/ageAssurance/AgeAssuranceAdmonition' 9 + import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy' 10 + import {Button, ButtonIcon} from '#/components/Button' 11 + import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' 12 + 13 + export function AgeAssuranceDismissibleNotice({style}: ViewStyleProp & {}) { 14 + const {_} = useLingui() 15 + const {isReady, isDeclaredUnderage, isAgeRestricted, lastInitiatedAt} = 16 + useAgeAssurance() 17 + const {nux} = useNux(Nux.AgeAssuranceDismissibleNotice) 18 + const copy = useAgeAssuranceCopy() 19 + const {mutate: save, variables} = useSaveNux() 20 + const hidden = !!variables 21 + 22 + if (!isReady) return null 23 + if (isDeclaredUnderage) return null 24 + if (!isAgeRestricted) return null 25 + if (lastInitiatedAt) return null 26 + if (hidden) return null 27 + if (nux && nux.completed) return null 28 + 29 + return ( 30 + <View style={style}> 31 + <View> 32 + <AgeAssuranceAdmonition>{copy.notice}</AgeAssuranceAdmonition> 33 + 34 + <Button 35 + label={_(msg`Don't show again`)} 36 + size="tiny" 37 + variant="solid" 38 + color="secondary_inverted" 39 + shape="round" 40 + onPress={() => 41 + save({ 42 + id: Nux.AgeAssuranceDismissibleNotice, 43 + completed: true, 44 + data: undefined, 45 + }) 46 + } 47 + style={[ 48 + a.absolute, 49 + { 50 + top: 12, 51 + right: 12, 52 + }, 53 + ]}> 54 + <ButtonIcon icon={X} /> 55 + </Button> 56 + </View> 57 + </View> 58 + ) 59 + }
+351
src/components/ageAssurance/AgeAssuranceInitDialog.tsx
··· 1 + import {useState} from 'react' 2 + import {View} from 'react-native' 3 + import {msg, Trans} from '@lingui/macro' 4 + import {useLingui} from '@lingui/react' 5 + import {validate as validateEmail} from 'email-validator' 6 + 7 + import {useCleanError} from '#/lib/hooks/useCleanError' 8 + import {useGetTimeAgo} from '#/lib/hooks/useTimeAgo' 9 + import {useTLDs} from '#/lib/hooks/useTLDs' 10 + import {isEmailMaybeInvalid} from '#/lib/strings/email' 11 + import {type AppLanguage} from '#/locale/languages' 12 + import {useAgeAssuranceContext} from '#/state/ageAssurance' 13 + import {useInitAgeAssurance} from '#/state/ageAssurance/useInitAgeAssurance' 14 + import {useLanguagePrefs} from '#/state/preferences' 15 + import {useSession} from '#/state/session' 16 + import {atoms as a, useTheme, web} from '#/alf' 17 + import {Admonition} from '#/components/Admonition' 18 + import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge' 19 + import {urls} from '#/components/ageAssurance/const' 20 + import {KWS_SUPPORTED_LANGS} from '#/components/ageAssurance/const' 21 + import {Button, ButtonIcon, ButtonText} from '#/components/Button' 22 + import * as Dialog from '#/components/Dialog' 23 + import {Divider} from '#/components/Divider' 24 + import * as TextField from '#/components/forms/TextField' 25 + import {ShieldCheck_Stroke2_Corner0_Rounded as Shield} from '#/components/icons/Shield' 26 + import {LanguageSelect} from '#/components/LanguageSelect' 27 + import {InlineLinkText} from '#/components/Link' 28 + import {Loader} from '#/components/Loader' 29 + import {Text} from '#/components/Typography' 30 + 31 + export {useDialogControl} from '#/components/Dialog/context' 32 + 33 + export function AgeAssuranceInitDialog({ 34 + control, 35 + }: { 36 + control: Dialog.DialogControlProps 37 + }) { 38 + const {_} = useLingui() 39 + return ( 40 + <Dialog.Outer control={control}> 41 + <Dialog.Handle /> 42 + 43 + <Dialog.ScrollableInner 44 + label={_( 45 + msg`Begin the age assurance process by completing the fields below.`, 46 + )} 47 + style={[ 48 + web({ 49 + maxWidth: 400, 50 + }), 51 + ]}> 52 + <Inner /> 53 + <Dialog.Close /> 54 + </Dialog.ScrollableInner> 55 + </Dialog.Outer> 56 + ) 57 + } 58 + 59 + function Inner() { 60 + const t = useTheme() 61 + const {_} = useLingui() 62 + const {currentAccount} = useSession() 63 + const langPrefs = useLanguagePrefs() 64 + const cleanError = useCleanError() 65 + const {close} = Dialog.useDialogContext() 66 + const {lastInitiatedAt} = useAgeAssuranceContext() 67 + const getTimeAgo = useGetTimeAgo() 68 + const tlds = useTLDs() 69 + 70 + const wasRecentlyInitiated = 71 + lastInitiatedAt && 72 + new Date(lastInitiatedAt).getTime() > Date.now() - 5 * 60 * 1000 // 5 minutes 73 + 74 + const [success, setSuccess] = useState(false) 75 + const [email, setEmail] = useState(currentAccount?.email || '') 76 + const [emailError, setEmailError] = useState<string>('') 77 + const [languageError, setLanguageError] = useState(false) 78 + const [disabled, setDisabled] = useState(false) 79 + const [language, setLanguage] = useState<string | undefined>( 80 + convertToKWSSupportedLanguage(langPrefs.appLanguage), 81 + ) 82 + const [error, setError] = useState<string>('') 83 + 84 + const {mutateAsync: init, isPending} = useInitAgeAssurance() 85 + 86 + const runEmailValidation = () => { 87 + if (validateEmail(email)) { 88 + setEmailError('') 89 + setDisabled(false) 90 + 91 + if (tlds && isEmailMaybeInvalid(email, tlds)) { 92 + setEmailError( 93 + _( 94 + msg`Please double-check that you have entered your email address correctly.`, 95 + ), 96 + ) 97 + return {status: 'maybe'} 98 + } 99 + 100 + return {status: 'valid'} 101 + } 102 + 103 + setEmailError(_(msg`Please enter a valid email address.`)) 104 + setDisabled(true) 105 + 106 + return {status: 'invalid'} 107 + } 108 + 109 + const onSubmit = async () => { 110 + setLanguageError(false) 111 + 112 + try { 113 + const {status} = runEmailValidation() 114 + 115 + if (status === 'invalid') return 116 + if (!language) { 117 + setLanguageError(true) 118 + return 119 + } 120 + 121 + await init({ 122 + email, 123 + language, 124 + }) 125 + 126 + setSuccess(true) 127 + } catch (e) { 128 + const {clean, raw} = cleanError(e) 129 + 130 + if (clean) { 131 + setError(clean || _(msg`Something went wrong, please try again`)) 132 + } else { 133 + let message = _(msg`Something went wrong, please try again`) 134 + 135 + if (raw) { 136 + if (raw.startsWith('This email address is not supported')) { 137 + message = _( 138 + msg`Please enter a valid, non-temporary email address. You may need to access this email in the future.`, 139 + ) 140 + } 141 + } 142 + 143 + setError(message) 144 + } 145 + } 146 + } 147 + 148 + return ( 149 + <View> 150 + <View style={[a.align_start]}> 151 + <AgeAssuranceBadge /> 152 + 153 + <Text style={[a.text_xl, a.font_heavy, a.pt_xl, a.pb_md]}> 154 + {success ? <Trans>Success!</Trans> : <Trans>Verify your age</Trans>} 155 + </Text> 156 + 157 + <View style={[a.pb_xl, a.gap_sm]}> 158 + {success ? ( 159 + <Text style={[a.text_sm, a.leading_snug]}> 160 + <Trans> 161 + Please check your email inbox for further instructions. It may 162 + take a minute or two to arrive. 163 + </Trans> 164 + </Text> 165 + ) : ( 166 + <> 167 + <Text style={[a.text_sm, a.leading_snug]}> 168 + <Trans> 169 + We use{' '} 170 + <InlineLinkText 171 + overridePresentation 172 + disableMismatchWarning 173 + label={_(msg`KWS website`)} 174 + to={urls.kwsHome} 175 + style={[a.text_sm, a.leading_snug]}> 176 + KWS 177 + </InlineLinkText>{' '} 178 + to verify that you’re an adult. When you click "Begin" below, 179 + KWS will email you instructions for verifying your age. When 180 + you’re done, you'll be brought back to continue using Bluesky. 181 + </Trans> 182 + </Text> 183 + <Text style={[a.text_sm, a.leading_snug]}> 184 + <Trans>This should only take a few minutes.</Trans> 185 + </Text> 186 + </> 187 + )} 188 + </View> 189 + 190 + {success ? ( 191 + <View style={[a.w_full]}> 192 + <Button 193 + label={_(msg`Close dialog`)} 194 + size="large" 195 + variant="solid" 196 + color="secondary" 197 + onPress={() => close()}> 198 + <ButtonText> 199 + <Trans>Close dialog</Trans> 200 + </ButtonText> 201 + </Button> 202 + </View> 203 + ) : ( 204 + <> 205 + <Divider /> 206 + 207 + <View style={[a.w_full, a.pt_xl, a.gap_lg, a.pb_lg]}> 208 + {wasRecentlyInitiated && ( 209 + <Admonition type="warning"> 210 + <Trans> 211 + You initiated this flow already,{' '} 212 + {getTimeAgo(lastInitiatedAt, new Date(), {format: 'long'})}{' '} 213 + ago. It may take up to 5 minutes for emails to reach your 214 + inbox. Please consider waiting a few minutes before trying 215 + again. 216 + </Trans> 217 + </Admonition> 218 + )} 219 + 220 + <View> 221 + <TextField.LabelText> 222 + <Trans>Your email</Trans> 223 + </TextField.LabelText> 224 + <TextField.Root isInvalid={!!emailError}> 225 + <TextField.Input 226 + label={_(msg`Your email`)} 227 + placeholder={_(msg`Your email`)} 228 + value={email} 229 + onChangeText={setEmail} 230 + onFocus={() => setEmailError('')} 231 + onBlur={() => { 232 + runEmailValidation() 233 + }} 234 + returnKeyType="done" 235 + autoCapitalize="none" 236 + autoComplete="off" 237 + autoCorrect={false} 238 + onSubmitEditing={onSubmit} 239 + /> 240 + </TextField.Root> 241 + 242 + {emailError ? ( 243 + <Admonition type="error" style={[a.mt_sm]}> 244 + {emailError} 245 + </Admonition> 246 + ) : ( 247 + <Admonition type="tip" style={[a.mt_sm]}> 248 + <Trans> 249 + Use your account email address, or another real email 250 + address you control, in case KWS or Bluesky needs to 251 + contact you. 252 + </Trans> 253 + </Admonition> 254 + )} 255 + </View> 256 + 257 + <View> 258 + <TextField.LabelText> 259 + <Trans>Your preferred language</Trans> 260 + </TextField.LabelText> 261 + <LanguageSelect 262 + value={language} 263 + onChange={value => { 264 + setLanguage(value) 265 + setLanguageError(false) 266 + }} 267 + items={KWS_SUPPORTED_LANGS} 268 + /> 269 + 270 + {languageError && ( 271 + <Admonition type="error" style={[a.mt_sm]}> 272 + <Trans>Please select a language</Trans> 273 + </Admonition> 274 + )} 275 + </View> 276 + 277 + {error && <Admonition type="error">{error}</Admonition>} 278 + 279 + <Button 280 + disabled={disabled} 281 + label={_(msg`Begin age assurance process`)} 282 + size="large" 283 + variant="solid" 284 + color="primary" 285 + onPress={onSubmit}> 286 + <ButtonText> 287 + <Trans>Begin</Trans> 288 + </ButtonText> 289 + <ButtonIcon 290 + icon={isPending ? Loader : Shield} 291 + position="right" 292 + /> 293 + </Button> 294 + </View> 295 + 296 + <Text 297 + style={[a.text_xs, a.leading_snug, t.atoms.text_contrast_medium]}> 298 + <Trans> 299 + By continuing, you agree to the{' '} 300 + <InlineLinkText 301 + overridePresentation 302 + disableMismatchWarning 303 + label={_(msg`KWS Terms of Use`)} 304 + to={urls.kwsTermsOfUse} 305 + style={[a.text_xs, a.leading_snug]}> 306 + KWS Terms of Use 307 + </InlineLinkText>{' '} 308 + and acknowledge that KWS will store your verified status with 309 + your hashed email address in accordance with the{' '} 310 + <InlineLinkText 311 + overridePresentation 312 + disableMismatchWarning 313 + label={_(msg`KWS Privacy Policy`)} 314 + to={urls.kwsPrivacyPolicy} 315 + style={[a.text_xs, a.leading_snug]}> 316 + KWS Privacy Policy 317 + </InlineLinkText> 318 + . This means you won’t need to verify again the next time you 319 + use this email for other apps, games, and services powered by 320 + KWS technology. 321 + </Trans> 322 + </Text> 323 + </> 324 + )} 325 + </View> 326 + </View> 327 + ) 328 + } 329 + 330 + // best-effort mapping of our languages to KWS supported languages 331 + function convertToKWSSupportedLanguage( 332 + appLanguage: string, 333 + ): string | undefined { 334 + // `${Enum}` is how you get a type of string union of the enum values (???) -sfn 335 + switch (appLanguage as `${AppLanguage}`) { 336 + // only en is supported 337 + case 'en-GB': 338 + return 'en' 339 + // pt-PT is pt (pt-BR is supported independently) 340 + case 'pt-PT': 341 + return 'pt' 342 + // only chinese (simplified) is supported, map all chinese variants 343 + case 'zh-Hans-CN': 344 + case 'zh-Hant-HK': 345 + case 'zh-Hant-TW': 346 + return 'zh-Hans' 347 + default: 348 + // try and map directly - if undefined, they will have to pick from the dropdown 349 + return KWS_SUPPORTED_LANGS.find(v => v.value === appLanguage)?.value 350 + } 351 + }
+196
src/components/ageAssurance/AgeAssuranceRedirectDialog.tsx
··· 1 + import {useEffect, useRef, useState} from 'react' 2 + import {View} from 'react-native' 3 + import {msg, Trans} from '@lingui/macro' 4 + import {useLingui} from '@lingui/react' 5 + 6 + import {retry} from '#/lib/async/retry' 7 + import {wait} from '#/lib/async/wait' 8 + import {isNative} from '#/platform/detection' 9 + import {useAgeAssuranceAPIContext} from '#/state/ageAssurance' 10 + import {useAgent} from '#/state/session' 11 + import {atoms as a, useTheme, web} from '#/alf' 12 + import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge' 13 + import {Button, ButtonText} from '#/components/Button' 14 + import * as Dialog from '#/components/Dialog' 15 + import {useGlobalDialogsControlContext} from '#/components/dialogs/Context' 16 + import {CircleInfo_Stroke2_Corner0_Rounded as ErrorIcon} from '#/components/icons/CircleInfo' 17 + import {Loader} from '#/components/Loader' 18 + import {Text} from '#/components/Typography' 19 + 20 + export type AgeAssuranceRedirectDialogState = { 21 + result: 'success' | 'unknown' 22 + actorDid: string 23 + } 24 + 25 + /** 26 + * Validate and parse the query parameters returned from the age assurance 27 + * redirect. If not valid, returns `undefined` and the dialog will not open. 28 + */ 29 + export function parseAgeAssuranceRedirectDialogState( 30 + state: { 31 + result?: string 32 + actorDid?: string 33 + } = {}, 34 + ): AgeAssuranceRedirectDialogState | undefined { 35 + let result: AgeAssuranceRedirectDialogState['result'] = 'unknown' 36 + const actorDid = state.actorDid 37 + 38 + switch (state.result) { 39 + case 'success': 40 + result = 'success' 41 + break 42 + case 'unknown': 43 + default: 44 + result = 'unknown' 45 + break 46 + } 47 + 48 + if (result && actorDid) { 49 + return { 50 + result, 51 + actorDid, 52 + } 53 + } 54 + } 55 + 56 + export function useAgeAssuranceRedirectDialogControl() { 57 + return useGlobalDialogsControlContext().ageAssuranceRedirectDialogControl 58 + } 59 + 60 + export function AgeAssuranceRedirectDialog() { 61 + const {_} = useLingui() 62 + const control = useAgeAssuranceRedirectDialogControl() 63 + 64 + // TODO for testing 65 + // Dialog.useAutoOpen(control.control, 3e3) 66 + 67 + return ( 68 + <Dialog.Outer control={control.control}> 69 + <Dialog.Handle /> 70 + 71 + <Dialog.ScrollableInner 72 + label={_(msg`Verifying your age assurance status`)} 73 + style={[web({maxWidth: 400})]}> 74 + <Inner optimisticState={control.value} /> 75 + </Dialog.ScrollableInner> 76 + </Dialog.Outer> 77 + ) 78 + } 79 + 80 + export function Inner({}: {optimisticState?: AgeAssuranceRedirectDialogState}) { 81 + const t = useTheme() 82 + const {_} = useLingui() 83 + const agent = useAgent() 84 + const polling = useRef(false) 85 + const unmounted = useRef(false) 86 + const control = useAgeAssuranceRedirectDialogControl() 87 + const [error, setError] = useState(false) 88 + const {refetch: refreshAgeAssuranceState} = useAgeAssuranceAPIContext() 89 + 90 + useEffect(() => { 91 + if (polling.current) return 92 + 93 + polling.current = true 94 + 95 + wait( 96 + 3e3, 97 + retry( 98 + 5, 99 + () => true, 100 + async () => { 101 + if (!agent.session) return 102 + if (unmounted.current) return 103 + 104 + const {data} = await agent.app.bsky.unspecced.getAgeAssuranceState() 105 + 106 + if (data.status !== 'assured') { 107 + throw new Error( 108 + `Polling for age assurance state did not receive assured status`, 109 + ) 110 + } 111 + 112 + return data 113 + }, 114 + 1e3, 115 + ), 116 + ) 117 + .then(async data => { 118 + if (!data) return 119 + if (!agent.session) return 120 + if (unmounted.current) return 121 + 122 + // success! update state 123 + await refreshAgeAssuranceState() 124 + 125 + control.clear() 126 + control.control.close() 127 + }) 128 + .catch(() => { 129 + if (unmounted.current) return 130 + setError(true) 131 + // try a refetch anyway 132 + refreshAgeAssuranceState() 133 + }) 134 + 135 + return () => { 136 + unmounted.current = true 137 + } 138 + }, [agent, control, refreshAgeAssuranceState]) 139 + 140 + return ( 141 + <> 142 + <View style={[a.align_start, a.w_full]}> 143 + <AgeAssuranceBadge /> 144 + 145 + <View 146 + style={[ 147 + a.flex_row, 148 + a.justify_between, 149 + a.align_center, 150 + a.gap_sm, 151 + a.pt_lg, 152 + a.pb_md, 153 + ]}> 154 + {error && <ErrorIcon size="md" fill={t.palette.negative_500} />} 155 + 156 + <Text style={[a.text_xl, a.font_heavy]}> 157 + {error ? <Trans>Connection issue</Trans> : <Trans>Verifying</Trans>} 158 + </Text> 159 + 160 + {!error && <Loader size="md" />} 161 + </View> 162 + 163 + <Text style={[a.text_md, a.leading_snug]}> 164 + {error ? ( 165 + <Trans> 166 + We were unable to receive the verification due to a connection 167 + issue. It may arrive later. If it does, your account will update 168 + automatically. 169 + </Trans> 170 + ) : ( 171 + <Trans> 172 + We're confirming your status with our servers. This dialog should 173 + close in a few seconds. 174 + </Trans> 175 + )} 176 + </Text> 177 + 178 + {error && isNative && ( 179 + <View style={[a.w_full, a.pt_lg]}> 180 + <Button 181 + label={_(msg`Close`)} 182 + size="large" 183 + variant="solid" 184 + color="secondary"> 185 + <ButtonText> 186 + <Trans>Close</Trans> 187 + </ButtonText> 188 + </Button> 189 + </View> 190 + )} 191 + </View> 192 + 193 + {error && <Dialog.Close />} 194 + </> 195 + ) 196 + }
+93
src/components/ageAssurance/AgeRestrictedScreen.tsx
··· 1 + import {View} from 'react-native' 2 + import {msg, Trans} from '@lingui/macro' 3 + import {useLingui} from '@lingui/react' 4 + 5 + import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance' 6 + import {atoms as a} from '#/alf' 7 + import {Admonition} from '#/components/Admonition' 8 + import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge' 9 + import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy' 10 + import {ButtonIcon, ButtonText} from '#/components/Button' 11 + import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron' 12 + import * as Layout from '#/components/Layout' 13 + import {Link} from '#/components/Link' 14 + import {Text} from '#/components/Typography' 15 + 16 + export function AgeRestrictedScreen({ 17 + children, 18 + screenTitle, 19 + infoText, 20 + }: { 21 + children: React.ReactNode 22 + screenTitle?: string 23 + infoText?: string 24 + }) { 25 + const {_} = useLingui() 26 + const copy = useAgeAssuranceCopy() 27 + const {isReady, isAgeRestricted} = useAgeAssurance() 28 + 29 + if (!isReady) { 30 + return ( 31 + <Layout.Screen> 32 + <Layout.Header.Outer> 33 + <Layout.Header.Content> 34 + <Layout.Header.TitleText> </Layout.Header.TitleText> 35 + </Layout.Header.Content> 36 + <Layout.Header.Slot /> 37 + </Layout.Header.Outer> 38 + <Layout.Content /> 39 + </Layout.Screen> 40 + ) 41 + } 42 + if (!isAgeRestricted) return children 43 + 44 + return ( 45 + <Layout.Screen> 46 + <Layout.Header.Outer> 47 + <Layout.Header.BackButton /> 48 + <Layout.Header.Content> 49 + <Layout.Header.TitleText> 50 + {screenTitle ?? <Trans>Unavailable</Trans>} 51 + </Layout.Header.TitleText> 52 + </Layout.Header.Content> 53 + <Layout.Header.Slot /> 54 + </Layout.Header.Outer> 55 + <Layout.Content> 56 + <View style={[a.p_lg]}> 57 + <View style={[a.align_start, a.pb_lg]}> 58 + <AgeAssuranceBadge /> 59 + </View> 60 + 61 + <View style={[a.gap_sm, a.pb_lg]}> 62 + <Text style={[a.text_xl, a.leading_snug, a.font_heavy]}> 63 + <Trans> 64 + You must verify your age in order to access this screen. 65 + </Trans> 66 + </Text> 67 + 68 + <Text style={[a.text_md, a.leading_snug]}> 69 + <Trans>{copy.notice}</Trans> 70 + </Text> 71 + </View> 72 + 73 + <View 74 + style={[a.flex_row, a.justify_between, a.align_center, a.pb_xl]}> 75 + <Link 76 + label={_(msg`Go to account settings`)} 77 + to="/settings/account" 78 + size="small" 79 + variant="solid" 80 + color="primary"> 81 + <ButtonText> 82 + <Trans>Go to account settings</Trans> 83 + </ButtonText> 84 + <ButtonIcon icon={ChevronRight} position="right" /> 85 + </Link> 86 + </View> 87 + 88 + {infoText && <Admonition type="tip">{infoText}</Admonition>} 89 + </View> 90 + </Layout.Content> 91 + </Layout.Screen> 92 + ) 93 + }
+26
src/components/ageAssurance/const.ts
··· 1 + export const urls = { 2 + kwsHome: 'https://www.kidswebservices.com/en-US', 3 + kwsTermsOfUse: 'https://www.kidswebservices.com/en-US/terms-of-use', 4 + kwsPrivacyPolicy: 'https://www.kidswebservices.com/en-US/privacy-policy', 5 + } 6 + 7 + export const KWS_SUPPORTED_LANGS = [ 8 + {value: 'en', label: 'English'}, 9 + {value: 'ar', label: 'العربية'}, 10 + {value: 'zh-Hans', label: '简体中文'}, 11 + {value: 'nl', label: 'Nederlands'}, 12 + {value: 'tl', label: 'Filipino'}, 13 + {value: 'fr', label: 'Français'}, 14 + {value: 'de', label: 'Deutsch'}, 15 + {value: 'id', label: 'Bahasa Indonesia'}, 16 + {value: 'it', label: 'Italiano'}, 17 + {value: 'ja', label: '日本語'}, 18 + {value: 'ko', label: '한국어'}, 19 + {value: 'pt', label: 'Português'}, 20 + {value: 'pt-BR', label: 'Português (Brasil)'}, 21 + {value: 'ru', label: 'Русский'}, 22 + {value: 'es', label: 'Español'}, 23 + {value: 'tr', label: 'Türkçe'}, 24 + {value: 'th', label: 'ภาษาไทย'}, 25 + {value: 'vi', label: 'Tiếng Việt'}, 26 + ]
+18
src/components/ageAssurance/useAgeAssuranceCopy.ts
··· 1 + import {useMemo} from 'react' 2 + import {msg} from '@lingui/macro' 3 + import {useLingui} from '@lingui/react' 4 + 5 + export function useAgeAssuranceCopy() { 6 + const {_} = useLingui() 7 + 8 + return useMemo(() => { 9 + return { 10 + notice: _( 11 + msg`The laws in your location require that you verify your age before accessing certain features on Bluesky like adult content and direct messaging.`, 12 + ), 13 + chatsInfoText: _( 14 + msg`Don't worry! All existing messages and settings are saved and will be available after you've been verified to be 18 or older.`, 15 + ), 16 + } 17 + }, [_]) 18 + }
+6
src/components/dialogs/Context.tsx
··· 1 1 import {createContext, useContext, useMemo, useState} from 'react' 2 2 3 + import {type AgeAssuranceRedirectDialogState} from '#/components/ageAssurance/AgeAssuranceRedirectDialog' 3 4 import * as Dialog from '#/components/Dialog' 4 5 import {type Screen} from '#/components/dialogs/EmailDialog/types' 5 6 ··· 22 23 displayText: string 23 24 share?: boolean 24 25 }> 26 + ageAssuranceRedirectDialogControl: StatefulControl<AgeAssuranceRedirectDialogState> 25 27 } 26 28 27 29 const ControlsContext = createContext<ControlsContext | null>(null) ··· 46 48 displayText: string 47 49 share?: boolean 48 50 }>() 51 + const ageAssuranceRedirectDialogControl = 52 + useStatefulDialogControl<AgeAssuranceRedirectDialogState>() 49 53 50 54 const ctx = useMemo<ControlsContext>( 51 55 () => ({ ··· 54 58 inAppBrowserConsentControl, 55 59 emailDialogControl, 56 60 linkWarningDialogControl, 61 + ageAssuranceRedirectDialogControl, 57 62 }), 58 63 [ 59 64 mutedWordsDialogControl, ··· 61 66 inAppBrowserConsentControl, 62 67 emailDialogControl, 63 68 linkWarningDialogControl, 69 + ageAssuranceRedirectDialogControl, 64 70 ], 65 71 ) 66 72
+23 -18
src/components/moderation/LabelPreference.tsx
··· 1 - import React from 'react' 2 1 import {View} from 'react-native' 3 - import {InterpretedLabelValueDefinition, LabelPreference} from '@atproto/api' 2 + import { 3 + type InterpretedLabelValueDefinition, 4 + type LabelPreference, 5 + } from '@atproto/api' 4 6 import {msg, Trans} from '@lingui/macro' 5 7 import {useLingui} from '@lingui/react' 8 + import type React from 'react' 6 9 7 10 import {useGlobalLabelStrings} from '#/lib/moderation/useGlobalLabelStrings' 8 11 import {useLabelBehaviorDescription} from '#/lib/moderation/useLabelBehaviorDescription' ··· 65 68 ignoreLabel, 66 69 warnLabel, 67 70 hideLabel, 71 + disabled, 68 72 }: { 69 73 name: string 70 74 values: ToggleButton.GroupProps['values'] ··· 72 76 ignoreLabel?: string 73 77 warnLabel?: string 74 78 hideLabel?: string 79 + disabled?: boolean 75 80 }) { 76 81 const {_} = useLingui() 77 82 78 83 return ( 79 84 <View style={[{minHeight: 35}, a.w_full]}> 80 85 <ToggleButton.Group 86 + disabled={disabled} 81 87 label={_( 82 88 msg`Configure content filtering setting for category: ${name}`, 83 89 )} ··· 143 149 name={labelStrings.name} 144 150 description={labelStrings.description} 145 151 /> 146 - {!disabled && ( 147 - <Buttons 148 - name={labelStrings.name.toLowerCase()} 149 - values={[pref]} 150 - onChange={values => { 151 - mutate({ 152 - label: identifier, 153 - visibility: values[0] as LabelPreference, 154 - labelerDid: undefined, 155 - }) 156 - }} 157 - ignoreLabel={labelOptions.ignore} 158 - warnLabel={labelOptions.warn} 159 - hideLabel={labelOptions.hide} 160 - /> 161 - )} 152 + <Buttons 153 + name={labelStrings.name.toLowerCase()} 154 + values={[pref]} 155 + onChange={values => { 156 + mutate({ 157 + label: identifier, 158 + visibility: values[0] as LabelPreference, 159 + labelerDid: undefined, 160 + }) 161 + }} 162 + ignoreLabel={labelOptions.ignore} 163 + warnLabel={labelOptions.warn} 164 + hideLabel={labelOptions.hide} 165 + disabled={disabled} 166 + /> 162 167 </Outer> 163 168 ) 164 169 }
+3
src/lib/constants.ts
··· 202 202 }, 203 203 } 204 204 205 + export const PUBLIC_APPVIEW = 'https://api.bsky.app' 205 206 export const PUBLIC_APPVIEW_DID = 'did:web:api.bsky.app' 206 207 export const PUBLIC_STAGING_APPVIEW_DID = 'did:web:api.staging.bsky.dev' 208 + 209 + export const DEV_ENV_APPVIEW = `http://localhost:2584` // always the same
+35 -3
src/lib/hooks/useIntentHandler.ts
··· 6 6 import {isNative} from '#/platform/detection' 7 7 import {useSession} from '#/state/session' 8 8 import {useCloseAllActiveElements} from '#/state/util' 9 + import { 10 + parseAgeAssuranceRedirectDialogState, 11 + useAgeAssuranceRedirectDialogControl, 12 + } from '#/components/ageAssurance/AgeAssuranceRedirectDialog' 9 13 import {useIntentDialogs} from '#/components/intents/IntentDialogs' 10 14 import {Referrer} from '../../../modules/expo-bluesky-swiss-army' 11 15 12 - type IntentType = 'compose' | 'verify-email' 16 + type IntentType = 'compose' | 'verify-email' | 'age-assurance' 13 17 14 18 const VALID_IMAGE_REGEX = /^[\w.:\-_/]+\|\d+(\.\d+)?\|\d+(\.\d+)?$/ 15 19 ··· 20 24 const incomingUrl = Linking.useURL() 21 25 const composeIntent = useComposeIntent() 22 26 const verifyEmailIntent = useVerifyEmailIntent() 27 + const ageAssuranceRedirectDialogControl = 28 + useAgeAssuranceRedirectDialogControl() 29 + const {currentAccount} = useSession() 23 30 24 31 React.useEffect(() => { 25 32 const handleIncomingURL = (url: string) => { ··· 65 72 verifyEmailIntent(code) 66 73 return 67 74 } 75 + case 'age-assurance': { 76 + const state = parseAgeAssuranceRedirectDialogState({ 77 + result: params.get('result') ?? undefined, 78 + actorDid: params.get('actorDid') ?? undefined, 79 + }) 80 + 81 + /* 82 + * If we don't have an account or the account doesn't match, do 83 + * nothing. By the time the user switches to their other account, AA 84 + * state should be ready for them. 85 + */ 86 + if ( 87 + state && 88 + currentAccount && 89 + state.actorDid === currentAccount.did 90 + ) { 91 + ageAssuranceRedirectDialogControl.open(state) 92 + } 93 + return 94 + } 68 95 default: { 69 96 return 70 97 } ··· 78 105 handleIncomingURL(incomingUrl) 79 106 previousIntentUrl = incomingUrl 80 107 } 81 - }, [incomingUrl, composeIntent, verifyEmailIntent]) 108 + }, [ 109 + incomingUrl, 110 + composeIntent, 111 + verifyEmailIntent, 112 + ageAssuranceRedirectDialogControl, 113 + currentAccount, 114 + ]) 82 115 } 83 116 84 117 export function useComposeIntent() { ··· 97 130 videoUri: string | null 98 131 }) => { 99 132 if (!hasSession) return 100 - 101 133 closeAllActiveElements() 102 134 103 135 // Whenever a video URI is present, we don't support adding images right now.
+15
src/lib/hooks/useTLDs.ts
··· 1 + import {useEffect, useState} from 'react' 2 + import type tldts from 'tldts' 3 + 4 + export function useTLDs() { 5 + const [tlds, setTlds] = useState<typeof tldts>() 6 + 7 + useEffect(() => { 8 + // @ts-expect-error - valid path 9 + import('tldts/dist/index.cjs.min.js').then(tlds => { 10 + setTlds(tlds) 11 + }) 12 + }, []) 13 + 14 + return tlds 15 + }
+68 -30
src/lib/notifications/notifications.ts
··· 2 2 import {Platform} from 'react-native' 3 3 import * as Notifications from 'expo-notifications' 4 4 import {getBadgeCountAsync, setBadgeCountAsync} from 'expo-notifications' 5 - import {type AtpAgent} from '@atproto/api' 5 + import {type AppBskyNotificationRegisterPush, type AtpAgent} from '@atproto/api' 6 6 import debounce from 'lodash.debounce' 7 7 8 8 import {PUBLIC_APPVIEW_DID, PUBLIC_STAGING_APPVIEW_DID} from '#/lib/constants' 9 9 import {logger as notyLogger} from '#/lib/notifications/util' 10 10 import {isNative} from '#/platform/detection' 11 + import {useAgeAssuranceContext} from '#/state/ageAssurance' 11 12 import {type SessionAccount, useAgent, useSession} from '#/state/session' 12 13 import BackgroundNotificationHandler from '#/../modules/expo-background-notification-handler' 13 14 ··· 19 20 agent, 20 21 currentAccount, 21 22 token, 23 + extra = {}, 22 24 }: { 23 25 agent: AtpAgent 24 26 currentAccount: SessionAccount 25 27 token: Notifications.DevicePushToken 28 + extra?: { 29 + ageRestricted?: boolean 30 + } 26 31 }) { 27 32 try { 28 - await agent.app.bsky.notification.registerPush({ 33 + const payload: AppBskyNotificationRegisterPush.InputSchema = { 29 34 serviceDid: currentAccount.service?.includes('staging') 30 35 ? PUBLIC_STAGING_APPVIEW_DID 31 36 : PUBLIC_APPVIEW_DID, 32 37 platform: Platform.OS, 33 38 token: token.data, 34 39 appId: 'xyz.blueskyweb.app', 35 - }) 40 + ageRestricted: extra.ageRestricted ?? false, 41 + } 42 + 43 + notyLogger.debug(`registerPushToken: registering`, {...payload}) 36 44 37 - notyLogger.debug(`registerPushToken: success`, { 38 - tokenType: token.type, 39 - token: token.data, 40 - }) 45 + await agent.app.bsky.notification.registerPush(payload) 46 + 47 + notyLogger.debug(`registerPushToken: success`) 41 48 } catch (error) { 42 49 notyLogger.error(`registerPushToken: failed`, {safeMessage: error}) 43 50 } ··· 61 68 const {currentAccount} = useSession() 62 69 63 70 return useCallback( 64 - ({token}: {token: Notifications.DevicePushToken}) => { 71 + ({ 72 + token, 73 + isAgeRestricted, 74 + }: { 75 + token: Notifications.DevicePushToken 76 + isAgeRestricted: boolean 77 + }) => { 65 78 if (!currentAccount) return 66 79 return _registerPushTokenDebounced({ 67 80 agent, 68 81 currentAccount, 69 82 token, 83 + extra: { 84 + ageRestricted: isAgeRestricted, 85 + }, 70 86 }) 71 87 }, 72 88 [agent, currentAccount], ··· 100 116 * it fires), so there's a possibility that multiple calls will be made, but 101 117 * that is acceptable. 102 118 * 103 - * @see https://github.com/bluesky-social/social-app/pull/4467 104 119 * @see https://github.com/expo/expo/issues/28656 105 120 * @see https://github.com/expo/expo/issues/29909 121 + * @see https://github.com/bluesky-social/social-app/pull/4467 106 122 */ 107 123 export function useGetAndRegisterPushToken() { 124 + const {isAgeRestricted} = useAgeAssuranceContext() 108 125 const registerPushToken = useRegisterPushToken() 109 - return useCallback(async () => { 110 - /** 111 - * This will also fire the listener added via `addPushTokenListener`. That 112 - * listener also handles registration. 113 - */ 114 - const token = await getPushToken() 115 - 116 - notyLogger.debug(`useGetAndRegisterPushToken`, { 117 - token: token ?? 'undefined', 118 - }) 126 + return useCallback( 127 + async ({ 128 + isAgeRestricted: isAgeRestrictedOverride, 129 + }: { 130 + isAgeRestricted?: boolean 131 + } = {}) => { 132 + if (!isNative) return 119 133 120 - if (token) { 121 134 /** 122 - * The listener should have registered the token already, but just in 123 - * case, call the debounced function again. 135 + * This will also fire the listener added via `addPushTokenListener`. That 136 + * listener also handles registration. 124 137 */ 125 - registerPushToken({token}) 126 - } 138 + const token = await getPushToken() 139 + 140 + notyLogger.debug(`useGetAndRegisterPushToken`, { 141 + token: token ?? 'undefined', 142 + }) 143 + 144 + if (token) { 145 + /** 146 + * The listener should have registered the token already, but just in 147 + * case, call the debounced function again. 148 + */ 149 + registerPushToken({ 150 + token, 151 + isAgeRestricted: isAgeRestrictedOverride ?? isAgeRestricted, 152 + }) 153 + } 127 154 128 - return token 129 - }, [registerPushToken]) 155 + return token 156 + }, 157 + [registerPushToken, isAgeRestricted], 158 + ) 130 159 } 131 160 132 161 /** ··· 140 169 const {currentAccount} = useSession() 141 170 const registerPushToken = useRegisterPushToken() 142 171 const getAndRegisterPushToken = useGetAndRegisterPushToken() 172 + const {isReady: isAgeRestrictionReady, isAgeRestricted} = 173 + useAgeAssuranceContext() 143 174 144 175 useEffect(() => { 145 176 /** 146 - * We want this to init right away _after_ we have a logged in user. 177 + * We want this to init right away _after_ we have a logged in user, and 178 + * _after_ we've loaded their age assurance state. 147 179 */ 148 - if (!currentAccount) return 180 + if (!currentAccount || !isAgeRestrictionReady) return 149 181 150 182 notyLogger.debug(`useNotificationsRegistration`) 151 183 ··· 167 199 * @see https://docs.expo.dev/versions/latest/sdk/notifications/#addpushtokenlistenerlistener 168 200 */ 169 201 const subscription = Notifications.addPushTokenListener(async token => { 170 - registerPushToken({token}) 202 + registerPushToken({token, isAgeRestricted: isAgeRestricted}) 171 203 notyLogger.debug(`addPushTokenListener callback`, {token}) 172 204 }) 173 205 174 206 return () => { 175 207 subscription.remove() 176 208 } 177 - }, [currentAccount, getAndRegisterPushToken, registerPushToken]) 209 + }, [ 210 + currentAccount, 211 + getAndRegisterPushToken, 212 + registerPushToken, 213 + isAgeRestrictionReady, 214 + isAgeRestricted, 215 + ]) 178 216 } 179 217 180 218 export function useRequestNotificationsPermission() {
+1
src/lib/statsig/gates.ts
··· 1 1 export type Gate = 2 2 // Keep this alphabetic please. 3 + | 'age_assurance' 3 4 | 'alt_share_icon' 4 5 | 'debug_show_feedcontext' 5 6 | 'debug_subscriptions'
+1
src/logger/types.ts
··· 12 12 ReportDialog = 'report-dialog', 13 13 FeedFeedback = 'feed-feedback', 14 14 PostSource = 'post-source', 15 + AgeAssurance = 'age-assurance', 15 16 16 17 /** 17 18 * METRIC IS FOR INTERNAL USE ONLY, don't create any other loggers using this
+17 -1
src/screens/Messages/ChatList.tsx
··· 23 23 import {List, type ListRef} from '#/view/com/util/List' 24 24 import {ChatListLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' 25 25 import {atoms as a, useBreakpoints, useTheme} from '#/alf' 26 + import {AgeRestrictedScreen} from '#/components/ageAssurance/AgeRestrictedScreen' 27 + import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy' 26 28 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 27 29 import {type DialogControlProps, useDialogControl} from '#/components/Dialog' 28 30 import {NewChat} from '#/components/dms/dialogs/NewChatDialog' ··· 64 66 } 65 67 66 68 type Props = NativeStackScreenProps<MessagesTabNavigatorParams, 'Messages'> 67 - export function MessagesScreen({navigation, route}: Props) { 69 + 70 + export function MessagesScreen(props: Props) { 71 + const {_} = useLingui() 72 + const aaCopy = useAgeAssuranceCopy() 73 + 74 + return ( 75 + <AgeRestrictedScreen 76 + screenTitle={_(msg`Chats`)} 77 + infoText={aaCopy.chatsInfoText}> 78 + <MessagesScreenInner {...props} /> 79 + </AgeRestrictedScreen> 80 + ) 81 + } 82 + 83 + export function MessagesScreenInner({navigation, route}: Props) { 68 84 const {_} = useLingui() 69 85 const t = useTheme() 70 86 const {currentAccount} = useSession()
+16 -1
src/screens/Messages/Conversation.tsx
··· 32 32 import {useSetMinimalShellMode} from '#/state/shell' 33 33 import {MessagesList} from '#/screens/Messages/components/MessagesList' 34 34 import {atoms as a, useBreakpoints, useTheme, web} from '#/alf' 35 + import {AgeRestrictedScreen} from '#/components/ageAssurance/AgeRestrictedScreen' 36 + import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy' 35 37 import { 36 38 EmailDialogScreenID, 37 39 useEmailDialogControl, ··· 46 48 CommonNavigatorParams, 47 49 'MessagesConversation' 48 50 > 49 - export function MessagesConversationScreen({route}: Props) { 51 + 52 + export function MessagesConversationScreen(props: Props) { 53 + const {_} = useLingui() 54 + const aaCopy = useAgeAssuranceCopy() 55 + return ( 56 + <AgeRestrictedScreen 57 + screenTitle={_(msg`Conversation`)} 58 + infoText={aaCopy.chatsInfoText}> 59 + <MessagesConversationScreenInner {...props} /> 60 + </AgeRestrictedScreen> 61 + ) 62 + } 63 + 64 + export function MessagesConversationScreenInner({route}: Props) { 50 65 const {gtMobile} = useBreakpoints() 51 66 const setMinimalShellMode = useSetMinimalShellMode() 52 67
+27 -6
src/screens/Messages/Inbox.tsx
··· 1 1 import {useCallback, useMemo, useState} from 'react' 2 2 import {View} from 'react-native' 3 - import {ChatBskyConvoDefs, ChatBskyConvoListConvos} from '@atproto/api' 3 + import { 4 + type ChatBskyConvoDefs, 5 + type ChatBskyConvoListConvos, 6 + } from '@atproto/api' 4 7 import {msg, Trans} from '@lingui/macro' 5 8 import {useLingui} from '@lingui/react' 6 9 import {useFocusEffect, useNavigation} from '@react-navigation/native' 7 - import {InfiniteData, UseInfiniteQueryResult} from '@tanstack/react-query' 10 + import { 11 + type InfiniteData, 12 + type UseInfiniteQueryResult, 13 + } from '@tanstack/react-query' 8 14 9 15 import {useAppState} from '#/lib/hooks/useAppState' 10 16 import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' 11 17 import { 12 - CommonNavigatorParams, 13 - NativeStackScreenProps, 14 - NavigationProp, 18 + type CommonNavigatorParams, 19 + type NativeStackScreenProps, 20 + type NavigationProp, 15 21 } from '#/lib/routes/types' 16 22 import {cleanError} from '#/lib/strings/errors' 17 23 import {logger} from '#/logger' ··· 26 32 import {ChatListLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' 27 33 import * as Toast from '#/view/com/util/Toast' 28 34 import {atoms as a, useBreakpoints, useTheme} from '#/alf' 35 + import {AgeRestrictedScreen} from '#/components/ageAssurance/AgeRestrictedScreen' 36 + import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy' 29 37 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 30 38 import {useRefreshOnFocus} from '#/components/hooks/useRefreshOnFocus' 31 39 import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeftIcon} from '#/components/icons/Arrow' ··· 39 47 import {RequestListItem} from './components/RequestListItem' 40 48 41 49 type Props = NativeStackScreenProps<CommonNavigatorParams, 'MessagesInbox'> 42 - export function MessagesInboxScreen({}: Props) { 50 + 51 + export function MessagesInboxScreen(props: Props) { 52 + const {_} = useLingui() 53 + const aaCopy = useAgeAssuranceCopy() 54 + return ( 55 + <AgeRestrictedScreen 56 + screenTitle={_(msg`Chat requests`)} 57 + infoText={aaCopy.chatsInfoText}> 58 + <MessagesInboxScreenInner {...props} /> 59 + </AgeRestrictedScreen> 60 + ) 61 + } 62 + 63 + export function MessagesInboxScreenInner({}: Props) { 43 64 const {gtTablet} = useBreakpoints() 44 65 45 66 const listConvosQuery = useListConvosQuery({status: 'request'})
+17 -1
src/screens/Messages/Settings.tsx
··· 12 12 import * as Toast from '#/view/com/util/Toast' 13 13 import {atoms as a} from '#/alf' 14 14 import {Admonition} from '#/components/Admonition' 15 + import {AgeRestrictedScreen} from '#/components/ageAssurance/AgeRestrictedScreen' 16 + import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy' 15 17 import {Divider} from '#/components/Divider' 16 18 import * as Toggle from '#/components/forms/Toggle' 17 19 import * as Layout from '#/components/Layout' ··· 21 23 type AllowIncoming = 'all' | 'none' | 'following' 22 24 23 25 type Props = NativeStackScreenProps<CommonNavigatorParams, 'MessagesSettings'> 24 - export function MessagesSettingsScreen({}: Props) { 26 + 27 + export function MessagesSettingsScreen(props: Props) { 28 + const {_} = useLingui() 29 + const aaCopy = useAgeAssuranceCopy() 30 + 31 + return ( 32 + <AgeRestrictedScreen 33 + screenTitle={_(msg`Chat Settings`)} 34 + infoText={aaCopy.chatsInfoText}> 35 + <MessagesSettingsScreenInner {...props} /> 36 + </AgeRestrictedScreen> 37 + ) 38 + } 39 + 40 + export function MessagesSettingsScreenInner({}: Props) { 25 41 const {_} = useLingui() 26 42 const {currentAccount} = useSession() 27 43 const {data: profile} = useProfileQuery({
+39 -20
src/screens/Moderation/index.tsx
··· 12 12 } from '#/lib/routes/types' 13 13 import {logger} from '#/logger' 14 14 import {isIOS} from '#/platform/detection' 15 + import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance' 15 16 import { 16 17 useMyLabelersQuery, 17 18 usePreferencesQuery, ··· 20 21 } from '#/state/queries/preferences' 21 22 import {isNonConfigurableModerationAuthority} from '#/state/session/additional-moderation-authorities' 22 23 import {useSetMinimalShellMode} from '#/state/shell' 23 - import {ViewHeader} from '#/view/com/util/ViewHeader' 24 24 import {atoms as a, useBreakpoints, useTheme, type ViewStyleProp} from '#/alf' 25 + import {AgeAssuranceAdmonition} from '#/components/ageAssurance/AgeAssuranceAdmonition' 25 26 import {Button, ButtonText} from '#/components/Button' 26 27 import * as Dialog from '#/components/Dialog' 27 28 import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings' ··· 84 85 error: preferencesError, 85 86 data: preferences, 86 87 } = usePreferencesQuery() 88 + const {isReady: isAgeInfoReady} = useAgeAssurance() 87 89 88 - const isLoading = isPreferencesLoading 90 + const isLoading = isPreferencesLoading || !isAgeInfoReady 89 91 const error = preferencesError 90 92 91 93 return ( 92 94 <Layout.Screen testID="moderationScreen"> 93 - <ViewHeader title={_(msg`Moderation`)} showOnDesktop /> 95 + <Layout.Header.Outer> 96 + <Layout.Header.BackButton /> 97 + <Layout.Header.Content> 98 + <Layout.Header.TitleText> 99 + <Trans>Moderation</Trans> 100 + </Layout.Header.TitleText> 101 + </Layout.Header.Content> 102 + <Layout.Header.Slot /> 103 + </Layout.Header.Outer> 94 104 <Layout.Content> 95 105 {isLoading ? ( 96 106 <ListMaybePlaceholder isLoading={true} sideBorders={false} /> ··· 157 167 data: labelers, 158 168 error: labelersError, 159 169 } = useMyLabelersQuery() 170 + const {declaredAge, isDeclaredUnderage, isAgeRestricted} = useAgeAssurance() 160 171 161 172 useFocusEffect( 162 173 useCallback(() => { ··· 170 181 (optimisticAdultContent && optimisticAdultContent.enabled) || 171 182 (!optimisticAdultContent && preferences.moderationPrefs.adultContentEnabled) 172 183 ) 173 - const ageNotSet = !preferences.userAge 174 - const isUnderage = (preferences.userAge || 0) < 18 175 184 176 185 const onToggleAdultContentEnabled = useCallback( 177 186 async (selected: boolean) => { ··· 306 315 <Trans>Content filters</Trans> 307 316 </Text> 308 317 318 + <AgeAssuranceAdmonition style={[a.pb_md]}> 319 + <Trans> 320 + You must complete age assurance in order to access the settings below. 321 + </Trans> 322 + </AgeAssuranceAdmonition> 323 + 309 324 <View style={[a.gap_md]}> 310 - {ageNotSet && ( 325 + {declaredAge === undefined && ( 311 326 <> 312 327 <Button 313 328 label={_(msg`Confirm your birthdate`)} ··· 336 351 a.overflow_hidden, 337 352 t.atoms.bg_contrast_25, 338 353 ]}> 339 - {!ageNotSet && !isUnderage && ( 354 + {!isDeclaredUnderage && !isAgeRestricted && ( 340 355 <> 341 356 <View 342 357 style={[ ··· 389 404 </View> 390 405 )} 391 406 <Divider /> 407 + 408 + {adultContentEnabled && ( 409 + <> 410 + <GlobalLabelPreference labelDefinition={LABELS.porn} /> 411 + <Divider /> 412 + <GlobalLabelPreference labelDefinition={LABELS.sexual} /> 413 + <Divider /> 414 + <GlobalLabelPreference 415 + labelDefinition={LABELS['graphic-media']} 416 + /> 417 + <Divider /> 418 + </> 419 + )} 392 420 </> 393 421 )} 394 - {!isUnderage && adultContentEnabled && ( 395 - <> 396 - <GlobalLabelPreference labelDefinition={LABELS.porn} /> 397 - <Divider /> 398 - <GlobalLabelPreference labelDefinition={LABELS.sexual} /> 399 - <Divider /> 400 - <GlobalLabelPreference 401 - labelDefinition={LABELS['graphic-media']} 402 - /> 403 - <Divider /> 404 - </> 405 - )} 406 - <GlobalLabelPreference labelDefinition={LABELS.nudity} /> 422 + <GlobalLabelPreference 423 + disabled={isDeclaredUnderage || isAgeRestricted} 424 + labelDefinition={LABELS.nudity} 425 + /> 407 426 </View> 408 427 </View> 409 428
+16
src/screens/Settings/AboutSettings.tsx
··· 20 20 import {CodeLines_Stroke2_Corner2_Rounded as CodeLinesIcon} from '#/components/icons/CodeLines' 21 21 import {Globe_Stroke2_Corner0_Rounded as GlobeIcon} from '#/components/icons/Globe' 22 22 import {Newspaper_Stroke2_Corner2_Rounded as NewspaperIcon} from '#/components/icons/Newspaper' 23 + import {ShieldCheck_Stroke2_Corner0_Rounded as Shield} from '#/components/icons/Shield' 23 24 import {Wrench_Stroke2_Corner2_Rounded as WrenchIcon} from '#/components/icons/Wrench' 24 25 import * as Layout from '#/components/Layout' 25 26 import {Loader} from '#/components/Loader' 27 + import {device} from '#/storage' 26 28 import {useDemoMode} from '#/storage/hooks/demo-mode' 27 29 import {useDevMode} from '#/storage/hooks/dev-mode' 28 30 import {OTAInfo} from './components/OTAInfo' ··· 179 181 </SettingsList.ItemText> 180 182 </SettingsList.PressableItem> 181 183 )} 184 + 185 + <SettingsList.PressableItem 186 + onPress={() => { 187 + device.set(['geolocation'], { 188 + countryCode: 'GB', 189 + isAgeRestrictedGeo: true, 190 + }) 191 + }} 192 + label="Simulate age restriction"> 193 + <SettingsList.ItemIcon icon={Shield} /> 194 + <SettingsList.ItemText> 195 + Simulate age restriction 196 + </SettingsList.ItemText> 197 + </SettingsList.PressableItem> 182 198 </> 183 199 )} 184 200 </SettingsList.Container>
+12 -10
src/screens/Settings/AccountSettings.tsx
··· 7 7 import {useSession} from '#/state/session' 8 8 import * as SettingsList from '#/screens/Settings/components/SettingsList' 9 9 import {atoms as a, useTheme} from '#/alf' 10 + import {AgeAssuranceAccountCard} from '#/components/ageAssurance/AgeAssuranceAccountCard' 10 11 import {useDialogControl} from '#/components/Dialog' 11 12 import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings' 12 13 import { ··· 114 115 <SettingsList.Chevron /> 115 116 </SettingsList.PressableItem> 116 117 <SettingsList.Divider /> 117 - <SettingsList.Item> 118 - <SettingsList.ItemIcon icon={BirthdayCakeIcon} /> 119 - <SettingsList.ItemText> 120 - <Trans>Birthday</Trans> 121 - </SettingsList.ItemText> 122 - <SettingsList.BadgeButton 123 - label={_(msg`Edit`)} 124 - onPress={() => birthdayControl.open()} 125 - /> 126 - </SettingsList.Item> 127 118 <SettingsList.PressableItem 128 119 label={_(msg`Password`)} 129 120 onPress={() => openModal({name: 'change-password'})}> ··· 143 134 </SettingsList.ItemText> 144 135 <SettingsList.Chevron /> 145 136 </SettingsList.PressableItem> 137 + <SettingsList.Item> 138 + <SettingsList.ItemIcon icon={BirthdayCakeIcon} /> 139 + <SettingsList.ItemText> 140 + <Trans>Birthday</Trans> 141 + </SettingsList.ItemText> 142 + <SettingsList.BadgeButton 143 + label={_(msg`Edit`)} 144 + onPress={() => birthdayControl.open()} 145 + /> 146 + </SettingsList.Item> 147 + <AgeAssuranceAccountCard style={[a.px_xl, a.pt_xs, a.pb_md]} /> 146 148 <SettingsList.Divider /> 147 149 <SettingsList.PressableItem 148 150 label={_(msg`Export my data`)}
+3
src/screens/Settings/Settings.tsx
··· 32 32 import {UserAvatar} from '#/view/com/util/UserAvatar' 33 33 import * as SettingsList from '#/screens/Settings/components/SettingsList' 34 34 import {atoms as a, platform, tokens, useBreakpoints, useTheme} from '#/alf' 35 + import {AgeAssuranceDismissibleNotice} from '#/components/ageAssurance/AgeAssuranceDismissibleNotice' 35 36 import {AvatarStackWithFetch} from '#/components/AvatarStack' 36 37 import {useDialogControl} from '#/components/Dialog' 37 38 import {SwitchAccountDialog} from '#/components/dialogs/SwitchAccount' ··· 96 97 </Layout.Header.Outer> 97 98 <Layout.Content> 98 99 <SettingsList.Container> 100 + <AgeAssuranceDismissibleNotice style={[a.px_lg, a.pt_xs, a.pb_xl]} /> 101 + 99 102 <View 100 103 style={[ 101 104 a.px_xl,
+11
src/state/ageAssurance/const.ts
··· 1 + import {type ModerationPrefs} from '@atproto/api' 2 + 3 + import {DEFAULT_LOGGED_OUT_LABEL_PREFERENCES} from '#/state/queries/preferences/moderation' 4 + 5 + export const AGE_RESTRICTED_MODERATION_PREFS: ModerationPrefs = { 6 + adultContentEnabled: false, 7 + labels: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES, 8 + labelers: [], 9 + mutedWords: [], 10 + hiddenPosts: [], 11 + }
+140
src/state/ageAssurance/index.tsx
··· 1 + import {createContext, useContext, useMemo} from 'react' 2 + import {type AppBskyUnspeccedDefs} from '@atproto/api' 3 + import {useQuery} from '@tanstack/react-query' 4 + 5 + import {networkRetry} from '#/lib/async/retry' 6 + import {useGetAndRegisterPushToken} from '#/lib/notifications/notifications' 7 + import {useGate} from '#/lib/statsig/statsig' 8 + import {isNetworkError} from '#/lib/strings/errors' 9 + import {Logger} from '#/logger' 10 + import { 11 + type AgeAssuranceAPIContextType, 12 + type AgeAssuranceContextType, 13 + } from '#/state/ageAssurance/types' 14 + import {useIsAgeAssuranceEnabled} from '#/state/ageAssurance/useIsAgeAssuranceEnabled' 15 + import {useGeolocation} from '#/state/geolocation' 16 + import {useAgent} from '#/state/session' 17 + 18 + const logger = Logger.create(Logger.Context.AgeAssurance) 19 + 20 + export const createAgeAssuranceQueryKey = (did: string) => 21 + ['ageAssurance', did] as const 22 + 23 + const DEFAULT_AGE_ASSURANCE_STATE: AppBskyUnspeccedDefs.AgeAssuranceState = { 24 + lastInitiatedAt: undefined, 25 + status: 'unknown', 26 + } 27 + 28 + const AgeAssuranceContext = createContext<AgeAssuranceContextType>({ 29 + status: 'unknown', 30 + isReady: false, 31 + lastInitiatedAt: undefined, 32 + isAgeRestricted: false, 33 + }) 34 + 35 + const AgeAssuranceAPIContext = createContext<AgeAssuranceAPIContextType>({ 36 + // @ts-ignore can't be bothered to type this 37 + refetch: () => Promise.resolve(), 38 + }) 39 + 40 + /** 41 + * Low-level provider for fetching age assurance state on app load. Do not add 42 + * any other data fetching in here to avoid complications and reduced 43 + * performance. 44 + */ 45 + export function Provider({children}: {children: React.ReactNode}) { 46 + const gate = useGate() 47 + const agent = useAgent() 48 + const {geolocation} = useGeolocation() 49 + const isAgeAssuranceEnabled = useIsAgeAssuranceEnabled() 50 + const getAndRegisterPushToken = useGetAndRegisterPushToken() 51 + 52 + const {data, isFetched, refetch} = useQuery({ 53 + /** 54 + * This is load bearing. We always want this query to run and end in a 55 + * "fetched" state, even if we fall back to defaults. This lets the rest of 56 + * the app know that we've at least attempted to load the AA state. 57 + * 58 + * However, it only needs to run if AA is enabled. 59 + */ 60 + enabled: isAgeAssuranceEnabled, 61 + queryKey: createAgeAssuranceQueryKey(agent.session?.did ?? 'never'), 62 + async queryFn() { 63 + if (!agent.session) return null 64 + 65 + try { 66 + const {data} = await networkRetry(3, () => 67 + agent.app.bsky.unspecced.getAgeAssuranceState(), 68 + ) 69 + // const {data} = { 70 + // data: { 71 + // lastInitiatedAt: new Date().toISOString(), 72 + // status: 'pending', 73 + // } as AppBskyUnspeccedDefs.AgeAssuranceState, 74 + // } 75 + 76 + logger.debug(`fetch`, { 77 + data, 78 + account: agent.session?.did, 79 + }) 80 + 81 + if (gate('age_assurance')) { 82 + await getAndRegisterPushToken({ 83 + isAgeRestricted: 84 + !!geolocation?.isAgeRestrictedGeo && data.status !== 'assured', 85 + }) 86 + } 87 + 88 + return data 89 + } catch (e) { 90 + if (!isNetworkError(e)) { 91 + logger.error(`ageAssurance: failed to fetch`, {safeMessage: e}) 92 + } 93 + // don't re-throw error, we'll just fall back to defaults 94 + return null 95 + } 96 + }, 97 + }) 98 + 99 + /** 100 + * Derive state, or fall back to defaults 101 + */ 102 + const ageAssuranceContext = useMemo<AgeAssuranceContextType>(() => { 103 + const {status, lastInitiatedAt} = data || DEFAULT_AGE_ASSURANCE_STATE 104 + const ctx: AgeAssuranceContextType = { 105 + isReady: isFetched || !isAgeAssuranceEnabled, 106 + status, 107 + lastInitiatedAt, 108 + isAgeRestricted: isAgeAssuranceEnabled ? status !== 'assured' : false, 109 + } 110 + logger.debug(`context`, ctx) 111 + return ctx 112 + }, [isFetched, data, isAgeAssuranceEnabled]) 113 + 114 + const ageAssuranceAPIContext = useMemo<AgeAssuranceAPIContextType>( 115 + () => ({ 116 + refetch, 117 + }), 118 + [refetch], 119 + ) 120 + 121 + return ( 122 + <AgeAssuranceAPIContext.Provider value={ageAssuranceAPIContext}> 123 + <AgeAssuranceContext.Provider value={ageAssuranceContext}> 124 + {children} 125 + </AgeAssuranceContext.Provider> 126 + </AgeAssuranceAPIContext.Provider> 127 + ) 128 + } 129 + 130 + /** 131 + * Access to low-level AA state. Prefer using {@link useAgeInfo} for a 132 + * more user-friendly interface. 133 + */ 134 + export function useAgeAssuranceContext() { 135 + return useContext(AgeAssuranceContext) 136 + } 137 + 138 + export function useAgeAssuranceAPIContext() { 139 + return useContext(AgeAssuranceAPIContext) 140 + }
+33
src/state/ageAssurance/types.ts
··· 1 + import {type AppBskyUnspeccedDefs} from '@atproto/api' 2 + import {type QueryObserverBaseResult} from '@tanstack/react-query' 3 + 4 + export type AgeAssuranceContextType = { 5 + /** 6 + * Whether the age assurance state has been fetched from the server. If user 7 + * is not in a region that requires AA, or AA is otherwise disabled, this 8 + * will always be `true`. 9 + */ 10 + isReady: boolean 11 + /** 12 + * The server-reported status of the user's age verification process. 13 + */ 14 + status: AppBskyUnspeccedDefs.AgeAssuranceState['status'] 15 + /** 16 + * The last time the age assurance state was attempted by the user. 17 + */ 18 + lastInitiatedAt: AppBskyUnspeccedDefs.AgeAssuranceState['lastInitiatedAt'] 19 + /** 20 + * Indicates the user is age restricted based on the requirements of their 21 + * region, and their server-provided age assurance status. Does not factor in 22 + * the user's declared age. If AA is otherise disabled, this will always be 23 + * `false`. 24 + */ 25 + isAgeRestricted: boolean 26 + } 27 + 28 + export type AgeAssuranceAPIContextType = { 29 + /** 30 + * Refreshes the age assurance state by fetching it from the server. 31 + */ 32 + refetch: QueryObserverBaseResult['refetch'] 33 + }
+45
src/state/ageAssurance/useAgeAssurance.ts
··· 1 + import {useMemo} from 'react' 2 + 3 + import {Logger} from '#/logger' 4 + import {useAgeAssuranceContext} from '#/state/ageAssurance' 5 + import {usePreferencesQuery} from '#/state/queries/preferences' 6 + 7 + const logger = Logger.create(Logger.Context.AgeAssurance) 8 + 9 + type AgeAssurance = ReturnType<typeof useAgeAssuranceContext> & { 10 + /** 11 + * The age the user has declared in their preferences, if any. 12 + */ 13 + declaredAge: number | undefined 14 + /** 15 + * Indicates whether the user has declared an age under 18. 16 + */ 17 + isDeclaredUnderage: boolean 18 + } 19 + 20 + /** 21 + * Computed age information based on age assurance status and the user's 22 + * declared age. Use this instead of {@link useAgeAssuranceContext} to get a 23 + * more user-friendly interface. 24 + */ 25 + export function useAgeAssurance(): AgeAssurance { 26 + const aa = useAgeAssuranceContext() 27 + const {isFetched: preferencesLoaded, data: preferences} = 28 + usePreferencesQuery() 29 + const declaredAge = preferences?.userAge 30 + 31 + return useMemo(() => { 32 + const isReady = aa.isReady && preferencesLoaded 33 + const isDeclaredUnderage = (declaredAge || 0) < 18 34 + const state: AgeAssurance = { 35 + isReady, 36 + status: aa.status, 37 + lastInitiatedAt: aa.lastInitiatedAt, 38 + isAgeRestricted: aa.isAgeRestricted, 39 + declaredAge, 40 + isDeclaredUnderage, 41 + } 42 + logger.debug(`state`, state) 43 + return state 44 + }, [aa, preferencesLoaded, declaredAge]) 45 + }
+85
src/state/ageAssurance/useInitAgeAssurance.ts
··· 1 + import { 2 + type AppBskyUnspeccedDefs, 3 + type AppBskyUnspeccedInitAgeAssurance, 4 + AtpAgent, 5 + } from '@atproto/api' 6 + import {useMutation, useQueryClient} from '@tanstack/react-query' 7 + 8 + import {wait} from '#/lib/async/wait' 9 + import { 10 + // DEV_ENV_APPVIEW, 11 + PUBLIC_APPVIEW, 12 + PUBLIC_APPVIEW_DID, 13 + } from '#/lib/constants' 14 + import {isNetworkError} from '#/lib/hooks/useCleanError' 15 + import {logger} from '#/logger' 16 + import {createAgeAssuranceQueryKey} from '#/state/ageAssurance' 17 + import {useGeolocation} from '#/state/geolocation' 18 + import {useAgent} from '#/state/session' 19 + 20 + let APPVIEW = PUBLIC_APPVIEW 21 + let APPVIEW_DID = PUBLIC_APPVIEW_DID 22 + 23 + /* 24 + * Uncomment if using the local dev-env 25 + */ 26 + // if (__DEV__) { 27 + // APPVIEW = DEV_ENV_APPVIEW 28 + // /* 29 + // * IMPORTANT: you need to get this value from `http://localhost:2581` 30 + // * introspection endpoint and updated in `constants`, since it changes 31 + // * every time you run the dev-env. 32 + // */ 33 + // APPVIEW_DID = `` 34 + // } 35 + 36 + export function useInitAgeAssurance() { 37 + const qc = useQueryClient() 38 + const agent = useAgent() 39 + const {geolocation} = useGeolocation() 40 + return useMutation({ 41 + async mutationFn( 42 + props: Omit<AppBskyUnspeccedInitAgeAssurance.InputSchema, 'countryCode'>, 43 + ) { 44 + if (!geolocation?.countryCode) { 45 + throw new Error(`Geolocation not available, cannot init age assurance.`) 46 + } 47 + 48 + const { 49 + data: {token}, 50 + } = await agent.com.atproto.server.getServiceAuth({ 51 + aud: APPVIEW_DID, 52 + lxm: `app.bsky.unspecced.initAgeAssurance`, 53 + }) 54 + 55 + const appView = new AtpAgent({service: APPVIEW}) 56 + appView.sessionManager.session = {...agent.session!} 57 + appView.sessionManager.session.accessJwt = token 58 + appView.sessionManager.session.refreshJwt = '' 59 + 60 + /* 61 + * 2s wait is good actually. Email sending takes a hot sec and this helps 62 + * ensure the email is ready for the user once they open their inbox. 63 + */ 64 + const {data} = await wait( 65 + 2e3, 66 + appView.app.bsky.unspecced.initAgeAssurance({ 67 + ...props, 68 + countryCode: geolocation?.countryCode?.toUpperCase(), 69 + }), 70 + ) 71 + 72 + qc.setQueryData<AppBskyUnspeccedDefs.AgeAssuranceState>( 73 + createAgeAssuranceQueryKey(agent.session?.did ?? 'never'), 74 + () => data, 75 + ) 76 + }, 77 + onError(e) { 78 + if (!isNetworkError(e)) { 79 + logger.error(`useInitAgeAssurance failed`, { 80 + safeMessage: e, 81 + }) 82 + } 83 + }, 84 + }) 85 + }
+13
src/state/ageAssurance/useIsAgeAssuranceEnabled.ts
··· 1 + import {useMemo} from 'react' 2 + 3 + import {useGate} from '#/lib/statsig/statsig' 4 + import {useGeolocation} from '#/state/geolocation' 5 + 6 + export function useIsAgeAssuranceEnabled() { 7 + const gate = useGate() 8 + const {geolocation} = useGeolocation() 9 + 10 + return useMemo(() => { 11 + return gate('age_assurance') && !!geolocation?.isAgeRestrictedGeo 12 + }, [geolocation, gate]) 13 + }
+5 -1
src/state/geolocation.tsx
··· 25 25 */ 26 26 export const DEFAULT_GEOLOCATION: Device['geolocation'] = { 27 27 countryCode: undefined, 28 + isAgeRestrictedGeo: false, 28 29 } 29 30 30 31 async function getGeolocation(): Promise<Device['geolocation']> { ··· 39 40 if (json.countryCode) { 40 41 return { 41 42 countryCode: json.countryCode, 43 + isAgeRestrictedGeo: json.isAgeRestrictedGeo ?? false, 42 44 } 43 45 } else { 44 46 return undefined ··· 66 68 */ 67 69 if (__DEV__) { 68 70 geolocationResolution = new Promise(y => y({success: true})) 69 - device.set(['geolocation'], DEFAULT_GEOLOCATION) 71 + if (!device.get(['geolocation'])) { 72 + device.set(['geolocation'], DEFAULT_GEOLOCATION) 73 + } 70 74 return 71 75 } 72 76
+12
src/state/queries/nuxs/definitions.ts
··· 7 7 ExploreInterestsCard = 'ExploreInterestsCard', 8 8 InitialVerificationAnnouncement = 'InitialVerificationAnnouncement', 9 9 ActivitySubscriptions = 'ActivitySubscriptions', 10 + AgeAssuranceDismissibleNotice = 'AgeAssuranceDismissibleNotice', 11 + AgeAssuranceDismissibleHeaderButton = 'AgeAssuranceDismissibleHeaderButton', 10 12 } 11 13 12 14 export const nuxNames = new Set(Object.values(Nux)) ··· 28 30 id: Nux.ActivitySubscriptions 29 31 data: undefined 30 32 } 33 + | { 34 + id: Nux.AgeAssuranceDismissibleNotice 35 + data: undefined 36 + } 37 + | { 38 + id: Nux.AgeAssuranceDismissibleHeaderButton 39 + data: undefined 40 + } 31 41 > 32 42 33 43 export const NuxSchemas: Record<Nux, zod.ZodObject<any> | undefined> = { ··· 35 45 [Nux.ExploreInterestsCard]: undefined, 36 46 [Nux.InitialVerificationAnnouncement]: undefined, 37 47 [Nux.ActivitySubscriptions]: undefined, 48 + [Nux.AgeAssuranceDismissibleNotice]: undefined, 49 + [Nux.AgeAssuranceDismissibleHeaderButton]: undefined, 38 50 }
+15 -1
src/state/queries/nuxs/index.ts
··· 1 1 import {useMutation, useQueryClient} from '@tanstack/react-query' 2 2 3 - import {AppNux, Nux} from '#/state/queries/nuxs/definitions' 3 + import {type AppNux, type Nux} from '#/state/queries/nuxs/definitions' 4 4 import {parseAppNux, serializeAppNux} from '#/state/queries/nuxs/util' 5 5 import { 6 6 preferencesQueryKey, ··· 39 39 } 40 40 } 41 41 } 42 + 43 + // if (__DEV__) { 44 + // const queryClient = useQueryClient() 45 + // const agent = useAgent() 46 + 47 + // // @ts-ignore 48 + // window.clearNux = async (ids: string[]) => { 49 + // await agent.bskyAppRemoveNuxs(ids) 50 + // // triggers a refetch 51 + // await queryClient.invalidateQueries({ 52 + // queryKey: preferencesQueryKey, 53 + // }) 54 + // } 55 + // } 42 56 43 57 return { 44 58 nuxs: undefined,
+23 -4
src/state/queries/post-feed.ts
··· 8 8 type BskyAgent, 9 9 moderatePost, 10 10 type ModerationDecision, 11 + type ModerationPrefs, 11 12 } from '@atproto/api' 12 13 import { 13 14 type InfiniteData, ··· 31 32 import {DISCOVER_FEED_URI} from '#/lib/constants' 32 33 import {BSKY_FEED_OWNER_DIDS} from '#/lib/constants' 33 34 import {logger} from '#/logger' 35 + import {useAgeAssuranceContext} from '#/state/ageAssurance' 34 36 import {STALE} from '#/state/queries' 35 37 import {DEFAULT_LOGGED_OUT_PREFERENCES} from '#/state/queries/preferences/const' 36 38 import {useAgent} from '#/state/session' ··· 134 136 const feedTuners = useFeedTuners(feedDesc) 135 137 const moderationOpts = useModerationOpts() 136 138 const {data: preferences} = usePreferencesQuery() 139 + /** 140 + * Load bearing: we need to await AA state or risk FOUC. This marginally 141 + * delays feeds, but AA state is fetched immediately on load and is then 142 + * available for the remainder of the session, so this delay only affects cold 143 + * loads. -esb 144 + */ 145 + const {isReady: isAgeAssuranceReady} = useAgeAssuranceContext() 137 146 const enabled = 138 - opts?.enabled !== false && Boolean(moderationOpts) && Boolean(preferences) 147 + opts?.enabled !== false && 148 + Boolean(moderationOpts) && 149 + Boolean(preferences) && 150 + isAgeAssuranceReady 139 151 const userInterests = aggregateUserInterests(preferences) 140 152 const followingPinnedIndex = 141 153 preferences?.savedFeeds?.findIndex( ··· 206 218 * some not. 207 219 */ 208 220 if (!agent.session) { 209 - assertSomePostsPassModeration(res.feed) 221 + assertSomePostsPassModeration( 222 + res.feed, 223 + preferences?.moderationPrefs || 224 + DEFAULT_LOGGED_OUT_PREFERENCES.moderationPrefs, 225 + ) 210 226 } 211 227 212 228 return { ··· 596 612 } 597 613 } 598 614 599 - function assertSomePostsPassModeration(feed: AppBskyFeedDefs.FeedViewPost[]) { 615 + function assertSomePostsPassModeration( 616 + feed: AppBskyFeedDefs.FeedViewPost[], 617 + moderationPrefs: ModerationPrefs, 618 + ) { 600 619 // no posts in this feed 601 620 if (feed.length === 0) return true 602 621 ··· 606 625 for (const item of feed) { 607 626 const moderation = moderatePost(item.post, { 608 627 userDid: undefined, 609 - prefs: DEFAULT_LOGGED_OUT_PREFERENCES.moderationPrefs, 628 + prefs: moderationPrefs, 610 629 }) 611 630 612 631 if (!moderation.ui('contentList').filter) {
+15
src/state/queries/preferences/index.ts
··· 1 + import {useCallback} from 'react' 1 2 import { 2 3 type AppBskyActorDefs, 3 4 type BskyFeedViewPreference, ··· 9 10 import {replaceEqualDeep} from '#/lib/functions' 10 11 import {getAge} from '#/lib/strings/time' 11 12 import {logger} from '#/logger' 13 + import {useAgeAssuranceContext} from '#/state/ageAssurance' 14 + import {AGE_RESTRICTED_MODERATION_PREFS} from '#/state/ageAssurance/const' 12 15 import {STALE} from '#/state/queries' 13 16 import { 14 17 DEFAULT_HOME_FEED_PREFS, ··· 31 34 32 35 export function usePreferencesQuery() { 33 36 const agent = useAgent() 37 + const {isAgeRestricted} = useAgeAssuranceContext() 38 + 34 39 return useQuery({ 35 40 staleTime: STALE.SECONDS.FIFTEEN, 36 41 structuralSharing: replaceEqualDeep, ··· 68 73 return preferences 69 74 } 70 75 }, 76 + select: useCallback( 77 + (data: UsePreferencesQueryResponse) => { 78 + const isUnderage = (data.userAge || 0) < 18 79 + if (isUnderage || isAgeRestricted) { 80 + data.moderationPrefs = AGE_RESTRICTED_MODERATION_PREFS 81 + } 82 + return data 83 + }, 84 + [isAgeRestricted], 85 + ), 71 86 }) 72 87 } 73 88
+1
src/storage/schema.ts
··· 7 7 lastNuxDialog: string | undefined 8 8 geolocation?: { 9 9 countryCode: string | undefined 10 + isAgeRestrictedGeo: boolean | undefined 10 11 } 11 12 trendingBetaEnabled: boolean 12 13 devMode: boolean
+2
src/view/shell/index.tsx
··· 25 25 import {ErrorBoundary} from '#/view/com/util/ErrorBoundary' 26 26 import {atoms as a, select, useTheme} from '#/alf' 27 27 import {setSystemUITheme} from '#/alf/util/systemUI' 28 + import {AgeAssuranceRedirectDialog} from '#/components/ageAssurance/AgeAssuranceRedirectDialog' 28 29 import {EmailDialog} from '#/components/dialogs/EmailDialog' 29 30 import {InAppBrowserConsentDialog} from '#/components/dialogs/InAppBrowserConsent' 30 31 import {LinkWarningDialog} from '#/components/dialogs/LinkWarning' ··· 155 156 <MutedWordsDialog /> 156 157 <SigninDialog /> 157 158 <EmailDialog /> 159 + <AgeAssuranceRedirectDialog /> 158 160 <InAppBrowserConsentDialog /> 159 161 <LinkWarningDialog /> 160 162 <Lightbox />
+2
src/view/shell/index.web.tsx
··· 17 17 import {ModalsContainer} from '#/view/com/modals/Modal' 18 18 import {ErrorBoundary} from '#/view/com/util/ErrorBoundary' 19 19 import {atoms as a, select, useTheme} from '#/alf' 20 + import {AgeAssuranceRedirectDialog} from '#/components/ageAssurance/AgeAssuranceRedirectDialog' 20 21 import {EmailDialog} from '#/components/dialogs/EmailDialog' 21 22 import {LinkWarningDialog} from '#/components/dialogs/LinkWarning' 22 23 import {MutedWordsDialog} from '#/components/dialogs/MutedWords' ··· 70 71 <MutedWordsDialog /> 71 72 <SigninDialog /> 72 73 <EmailDialog /> 74 + <AgeAssuranceRedirectDialog /> 73 75 <LinkWarningDialog /> 74 76 <Lightbox /> 75 77 <PortalOutlet />
+87 -84
yarn.lock
··· 55 55 resolved "https://registry.yarnpkg.com/@atproto-labs/simple-store/-/simple-store-0.2.0.tgz#f39098747dabf8a245d0ed6edc50f362aa4d95f8" 56 56 integrity sha512-0bRbAlI8Ayh03wRwncAMEAyUKtZ+AuTS1jgPrfym1WVOAOiottI/ZmgccqLl6w5MbxVcClNQF7WYGKvGwGoIhA== 57 57 58 - "@atproto-labs/xrpc-utils@0.0.16": 59 - version "0.0.16" 60 - resolved "https://registry.yarnpkg.com/@atproto-labs/xrpc-utils/-/xrpc-utils-0.0.16.tgz#f76c4f615685c60997401f052cbd9f0145d12576" 61 - integrity sha512-WvTQhGjIhFrd/0pMGecE7Xn8BtvvKAgVlNs8UaE6CVRifiCOIvIBwlx1vnslJAavK3FtwL1kKkUdxNtxHciZSQ== 58 + "@atproto-labs/xrpc-utils@0.0.17": 59 + version "0.0.17" 60 + resolved "https://registry.yarnpkg.com/@atproto-labs/xrpc-utils/-/xrpc-utils-0.0.17.tgz#c9ff68943a20957ec6e41ed347c73072c53d8755" 61 + integrity sha512-2kEfhe3F4GxW5grpfXxMo4fxHuEdDhj5D10YDJ0aC2BvYab9Y/67DDor7a63IptTgJooKNSweXochCUqOw4I8w== 62 62 dependencies: 63 - "@atproto/xrpc" "^0.7.0" 64 - "@atproto/xrpc-server" "^0.8.0" 63 + "@atproto/xrpc" "^0.7.1" 64 + "@atproto/xrpc-server" "^0.9.0" 65 65 66 - "@atproto/api@^0.15.21": 67 - version "0.15.21" 68 - resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.15.21.tgz#6cd450c49dc30ea7baca4905b9046abf69f9c1bd" 69 - integrity sha512-/VsikzVqIjNrdCk3eoJAleNcPUAGOLW8GCU9ymQMyGg1bBOCDb2Gl4eCqvhJ7Zd/UUyU5o8bh2YwLsY8/ikkeA== 66 + "@atproto/api@^0.15.26": 67 + version "0.15.26" 68 + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.15.26.tgz#452019d6d0753d4caa0f7941e8e87e9f8bfbee52" 69 + integrity sha512-AdXGjeCpLZiP9YMGi4YOdK1ayqkBhklmGfSG8UefqR6tTHth59PZvYs5KiwLnFhedt2Xljt3eUlhkn14Y48wEA== 70 70 dependencies: 71 71 "@atproto/common-web" "^0.4.2" 72 - "@atproto/lexicon" "^0.4.11" 72 + "@atproto/lexicon" "^0.4.12" 73 73 "@atproto/syntax" "^0.4.0" 74 - "@atproto/xrpc" "^0.7.0" 74 + "@atproto/xrpc" "^0.7.1" 75 75 await-lock "^2.2.2" 76 76 multiformats "^9.9.0" 77 77 tlds "^1.234.0" 78 78 zod "^3.23.8" 79 79 80 - "@atproto/aws@^0.2.24": 81 - version "0.2.24" 82 - resolved "https://registry.yarnpkg.com/@atproto/aws/-/aws-0.2.24.tgz#c8e7a804710d70be3aa2fa292c1ece4c05127891" 83 - integrity sha512-4XZQGitPJR56tFt1bzPJKOqp3vTVcfVsEAFo9FGWp7Es+jj742aVgfWEe64O0VoZp3ZTiD7XhwsLJArz7NJTlQ== 80 + "@atproto/aws@^0.2.25": 81 + version "0.2.25" 82 + resolved "https://registry.yarnpkg.com/@atproto/aws/-/aws-0.2.25.tgz#d07265a656db990ffd54b254cae54388468d1dca" 83 + integrity sha512-LT4uuda2mjXz2WT4xo7g2aWmWKl+JWusGzscqQpOlD/RFGFXKDmUcVWLVPKY+9Pys2F7X6tyDlm2aUx+/dYdYA== 84 84 dependencies: 85 85 "@atproto/common" "^0.4.11" 86 86 "@atproto/crypto" "^0.4.4" 87 - "@atproto/repo" "^0.8.4" 87 + "@atproto/repo" "^0.8.5" 88 88 "@aws-sdk/client-cloudfront" "^3.261.0" 89 89 "@aws-sdk/client-kms" "^3.196.0" 90 90 "@aws-sdk/client-s3" "^3.224.0" ··· 94 94 multiformats "^9.9.0" 95 95 uint8arrays "3.0.0" 96 96 97 - "@atproto/bsky@^0.0.167": 98 - version "0.0.167" 99 - resolved "https://registry.yarnpkg.com/@atproto/bsky/-/bsky-0.0.167.tgz#583eb404ef4de409e34d7c2485bf325e5d1f3ff0" 100 - integrity sha512-VLgaVsx0fYeoXcFHP1KM6joda9Ovhb7LsE3JdES6+hhsAF74DFwW57mVzRfYhy1bwWn/m9poUMs1RkCjOR9ZJA== 97 + "@atproto/bsky@^0.0.172": 98 + version "0.0.172" 99 + resolved "https://registry.yarnpkg.com/@atproto/bsky/-/bsky-0.0.172.tgz#963e3e2bc661e05c03fcb58788b2621d1afa6a3e" 100 + integrity sha512-P/oDJ4i4TuRBV8pQg8hht147jl+OruDYAXdVmUaHRMWPnry+godnfp6vzph9W9Y03DN+G320Z63dCfvFZquSUQ== 101 101 dependencies: 102 102 "@atproto-labs/fetch-node" "0.1.9" 103 - "@atproto-labs/xrpc-utils" "0.0.16" 104 - "@atproto/api" "^0.15.21" 103 + "@atproto-labs/xrpc-utils" "0.0.17" 104 + "@atproto/api" "^0.15.26" 105 105 "@atproto/common" "^0.4.11" 106 106 "@atproto/crypto" "^0.4.4" 107 107 "@atproto/did" "^0.1.5" 108 108 "@atproto/identity" "^0.4.8" 109 - "@atproto/lexicon" "^0.4.11" 110 - "@atproto/repo" "^0.8.4" 111 - "@atproto/sync" "^0.1.28" 109 + "@atproto/lexicon" "^0.4.12" 110 + "@atproto/repo" "^0.8.5" 111 + "@atproto/sync" "^0.1.29" 112 112 "@atproto/syntax" "^0.4.0" 113 - "@atproto/xrpc-server" "^0.8.0" 113 + "@atproto/xrpc-server" "^0.9.0" 114 114 "@bufbuild/protobuf" "^1.5.0" 115 115 "@connectrpc/connect" "^1.1.4" 116 116 "@connectrpc/connect-express" "^1.1.4" 117 117 "@connectrpc/connect-node" "^1.1.4" 118 118 "@did-plc/lib" "^0.0.1" 119 + "@hapi/address" "^5.1.1" 119 120 "@types/http-errors" "^2.0.1" 120 121 compression "^1.7.4" 121 122 cors "^2.8.5" 123 + disposable-email-domains-js "^1.5.0" 122 124 etcd3 "^1.1.2" 123 125 express "^4.17.2" 124 126 http-errors "^2.0.0" ··· 139 141 typed-emitter "^2.1.0" 140 142 uint8arrays "3.0.0" 141 143 undici "^6.19.8" 144 + zod "3.23.8" 142 145 143 146 "@atproto/bsync@^0.0.20": 144 147 version "0.0.20" ··· 218 221 "@noble/hashes" "^1.6.1" 219 222 uint8arrays "3.0.0" 220 223 221 - "@atproto/dev-env@^0.3.150": 222 - version "0.3.150" 223 - resolved "https://registry.yarnpkg.com/@atproto/dev-env/-/dev-env-0.3.150.tgz#6443206352398be1e3dd8bcfe980e7a21d2cd93a" 224 - integrity sha512-LOujaEmOVBCxSnKQqpJb238fe5vYGIgmTA+OMEFH3kZb+6Y6UXfW2Vhs79tP0DiX0VyoXwib/7PH3Lp5cC/ZFQ== 224 + "@atproto/dev-env@^0.3.155": 225 + version "0.3.155" 226 + resolved "https://registry.yarnpkg.com/@atproto/dev-env/-/dev-env-0.3.155.tgz#26f865b92e9241d3e39c1c81e38c8422f2df26e2" 227 + integrity sha512-KNrArdTAfrZQsRsFnwLC1Vgqh27brD5G2ZI0E3qF9BXQw2iiCH1r46IAhhggugL6xo3VXYucmM6/HGX+bKyavw== 225 228 dependencies: 226 - "@atproto/api" "^0.15.21" 227 - "@atproto/bsky" "^0.0.167" 229 + "@atproto/api" "^0.15.26" 230 + "@atproto/bsky" "^0.0.172" 228 231 "@atproto/bsync" "^0.0.20" 229 232 "@atproto/common-web" "^0.4.2" 230 233 "@atproto/crypto" "^0.4.4" 231 234 "@atproto/identity" "^0.4.8" 232 - "@atproto/lexicon" "^0.4.11" 233 - "@atproto/ozone" "^0.1.126" 234 - "@atproto/pds" "^0.4.156" 235 - "@atproto/sync" "^0.1.28" 235 + "@atproto/lexicon" "^0.4.12" 236 + "@atproto/ozone" "^0.1.131" 237 + "@atproto/pds" "^0.4.161" 238 + "@atproto/sync" "^0.1.29" 236 239 "@atproto/syntax" "^0.4.0" 237 - "@atproto/xrpc-server" "^0.8.0" 240 + "@atproto/xrpc-server" "^0.9.0" 238 241 "@did-plc/lib" "^0.0.1" 239 242 "@did-plc/server" "^0.0.1" 240 243 dotenv "^16.0.3" ··· 275 278 multiformats "^9.9.0" 276 279 zod "^3.23.8" 277 280 278 - "@atproto/lexicon@^0.4.11": 279 - version "0.4.11" 280 - resolved "https://registry.yarnpkg.com/@atproto/lexicon/-/lexicon-0.4.11.tgz#d5d09be1faf1d28d1e57051dab4064101f8b1617" 281 - integrity sha512-btefdnvNz2Ao2I+qbmj0F06HC8IlrM/IBz6qOBS50r0S6uDf5tOO+Mv2tSVdimFkdzyDdLtBI1sV36ONxz2cOw== 281 + "@atproto/lexicon@^0.4.12": 282 + version "0.4.12" 283 + resolved "https://registry.yarnpkg.com/@atproto/lexicon/-/lexicon-0.4.12.tgz#89a704789d983f8405a52095769b5b58d87f5af7" 284 + integrity sha512-fcEvEQ1GpQYF5igZ4IZjPWEoWVpsEF22L9RexxLS3ptfySXLflEyH384e7HITzO/73McDeaJx3lqHIuqn9ulnw== 282 285 dependencies: 283 286 "@atproto/common-web" "^0.4.2" 284 287 "@atproto/syntax" "^0.4.0" ··· 347 350 "@atproto/jwk" "0.4.0" 348 351 zod "^3.23.8" 349 352 350 - "@atproto/ozone@^0.1.126": 351 - version "0.1.126" 352 - resolved "https://registry.yarnpkg.com/@atproto/ozone/-/ozone-0.1.126.tgz#a4502121b9732a494a8b25a04be89b7eb0a4e2dd" 353 - integrity sha512-h1yP1NArjjHlOam9wamGIUSrG9tGynkZ0+Y6t21u7dwrg1o/TRpXSXemCYZhtz3zqdd4Yu5VyavoWPtEFdr+rQ== 353 + "@atproto/ozone@^0.1.131": 354 + version "0.1.131" 355 + resolved "https://registry.yarnpkg.com/@atproto/ozone/-/ozone-0.1.131.tgz#b097c0274f424afd6af4e891cf78c563395b65b3" 356 + integrity sha512-xGp0KPK89SXwtXHYza5kYNkwizCdXvn1sq0+5gwR6U8qNrvw0ibxBVMgtHTmqo9633tGD0c0woza1j+r5adm1w== 354 357 dependencies: 355 - "@atproto/api" "^0.15.21" 358 + "@atproto/api" "^0.15.26" 356 359 "@atproto/common" "^0.4.11" 357 360 "@atproto/crypto" "^0.4.4" 358 361 "@atproto/identity" "^0.4.8" 359 - "@atproto/lexicon" "^0.4.11" 362 + "@atproto/lexicon" "^0.4.12" 360 363 "@atproto/syntax" "^0.4.0" 361 - "@atproto/xrpc" "^0.7.0" 362 - "@atproto/xrpc-server" "^0.8.0" 364 + "@atproto/xrpc" "^0.7.1" 365 + "@atproto/xrpc-server" "^0.9.0" 363 366 "@did-plc/lib" "^0.0.1" 364 367 compression "^1.7.4" 365 368 cors "^2.8.5" ··· 377 380 undici "^6.14.1" 378 381 ws "^8.12.0" 379 382 380 - "@atproto/pds@^0.4.156": 381 - version "0.4.156" 382 - resolved "https://registry.yarnpkg.com/@atproto/pds/-/pds-0.4.156.tgz#1815ced4ab8b51cf9fe9a5712cd136a0b1d82392" 383 - integrity sha512-/8j/ihTLRhCI1sxkEvs2kuX4ehPKvsnwDxhmhdVvYqbKrjmGRTsDIZDV1K7dVFcYdCypOEPXsgTReh2lVhcC8w== 383 + "@atproto/pds@^0.4.161": 384 + version "0.4.161" 385 + resolved "https://registry.yarnpkg.com/@atproto/pds/-/pds-0.4.161.tgz#1f74675d5d5e4ca56361c89926571646b4c641ee" 386 + integrity sha512-az1PGUCwIEx/b1neWBh9lgv27nbtczinGazI/ccG5A0AOMps1MXpTIJgEz5yJ1c6mSrhZlP0sL1EO6uC89irNA== 384 387 dependencies: 385 388 "@atproto-labs/fetch-node" "0.1.9" 386 - "@atproto-labs/xrpc-utils" "0.0.16" 387 - "@atproto/api" "^0.15.21" 388 - "@atproto/aws" "^0.2.24" 389 + "@atproto-labs/xrpc-utils" "0.0.17" 390 + "@atproto/api" "^0.15.26" 391 + "@atproto/aws" "^0.2.25" 389 392 "@atproto/common" "^0.4.11" 390 393 "@atproto/crypto" "^0.4.4" 391 394 "@atproto/identity" "^0.4.8" 392 - "@atproto/lexicon" "^0.4.11" 395 + "@atproto/lexicon" "^0.4.12" 393 396 "@atproto/oauth-provider" "^0.9.3" 394 - "@atproto/repo" "^0.8.4" 397 + "@atproto/repo" "^0.8.5" 395 398 "@atproto/syntax" "^0.4.0" 396 - "@atproto/xrpc" "^0.7.0" 397 - "@atproto/xrpc-server" "^0.8.0" 399 + "@atproto/xrpc" "^0.7.1" 400 + "@atproto/xrpc-server" "^0.9.0" 398 401 "@did-plc/lib" "^0.0.4" 399 402 "@hapi/address" "^5.1.1" 400 403 better-sqlite3 "^10.0.0" ··· 424 427 undici "^6.19.8" 425 428 zod "^3.23.8" 426 429 427 - "@atproto/repo@^0.8.4": 428 - version "0.8.4" 429 - resolved "https://registry.yarnpkg.com/@atproto/repo/-/repo-0.8.4.tgz#f6a1b4bce8cf86cd1825069f9cd2916a5f86e774" 430 - integrity sha512-WgyARo6UcOnhbRsRVuNjXOH5MPTTHVDsaIavPeQl5erq5foE/pQKC7B7FLTJmhpC6GPZHJ5M2doAyXRXv5UHGA== 430 + "@atproto/repo@^0.8.5": 431 + version "0.8.5" 432 + resolved "https://registry.yarnpkg.com/@atproto/repo/-/repo-0.8.5.tgz#b1e8d49ac92b813a210aa6a696496220010c99f8" 433 + integrity sha512-QZ4UWBWDyPMXgPhktmaRYRyCXIw7lIEAyGtaFy7UmCPpJ5TtFKw3GhGrEiNz/fY3/6lrkdDj44/Tzkud/eP/VQ== 431 434 dependencies: 432 435 "@atproto/common" "^0.4.11" 433 436 "@atproto/common-web" "^0.4.2" 434 437 "@atproto/crypto" "^0.4.4" 435 - "@atproto/lexicon" "^0.4.11" 438 + "@atproto/lexicon" "^0.4.12" 436 439 "@ipld/dag-cbor" "^7.0.0" 437 440 multiformats "^9.9.0" 438 441 uint8arrays "3.0.0" 439 442 varint "^6.0.0" 440 443 zod "^3.23.8" 441 444 442 - "@atproto/sync@^0.1.28": 443 - version "0.1.28" 444 - resolved "https://registry.yarnpkg.com/@atproto/sync/-/sync-0.1.28.tgz#7c5c469dd899b4be86e5d993af66646c71d63eaf" 445 - integrity sha512-faCsOwcYQHxHmNWRPykV0hTccXaG15XoUMZozfmoFOKFSliTgDETTovSAVe05mNSBUvMWUGl8fdEwHRzq1Q8sA== 445 + "@atproto/sync@^0.1.29": 446 + version "0.1.29" 447 + resolved "https://registry.yarnpkg.com/@atproto/sync/-/sync-0.1.29.tgz#fb628e9f8a3562caa72fab6d3888790fc08ab29e" 448 + integrity sha512-tKIhbY4rfCCfinBapnGf7X276dron9A7NT7VFB1Wa9NqODjoJPGDBaRG8s9WmeeIknMgJquhRJOrYu0hQUupTQ== 446 449 dependencies: 447 450 "@atproto/common" "^0.4.11" 448 451 "@atproto/identity" "^0.4.8" 449 - "@atproto/lexicon" "^0.4.11" 450 - "@atproto/repo" "^0.8.4" 452 + "@atproto/lexicon" "^0.4.12" 453 + "@atproto/repo" "^0.8.5" 451 454 "@atproto/syntax" "^0.4.0" 452 - "@atproto/xrpc-server" "^0.8.0" 455 + "@atproto/xrpc-server" "^0.9.0" 453 456 multiformats "^9.9.0" 454 457 p-queue "^6.6.2" 455 458 ws "^8.12.0" ··· 459 462 resolved "https://registry.yarnpkg.com/@atproto/syntax/-/syntax-0.4.0.tgz#bec71552087bb24c208a06ef418c0040b65542f2" 460 463 integrity sha512-b9y5ceHS8YKOfP3mdKmwAx5yVj9294UN7FG2XzP6V5aKUdFazEYRnR9m5n5ZQFKa3GNvz7de9guZCJ/sUTcOAA== 461 464 462 - "@atproto/xrpc-server@^0.8.0": 463 - version "0.8.0" 464 - resolved "https://registry.yarnpkg.com/@atproto/xrpc-server/-/xrpc-server-0.8.0.tgz#a32c9c71411ec6ee476fcd0260d5e9e80be348bd" 465 - integrity sha512-jDAEVHVhM4IvC0y491gXBuD4b1D9/XrM3HaEronRneAdNZ0qE0nsiJNqiHfQ6r4BvFdHnABM9KyHV9EQTvmxfg== 465 + "@atproto/xrpc-server@^0.9.0": 466 + version "0.9.0" 467 + resolved "https://registry.yarnpkg.com/@atproto/xrpc-server/-/xrpc-server-0.9.0.tgz#d4b2f4194327eac456381b313280ac5c1c27ddd3" 468 + integrity sha512-vREyUFx4EiOtPYfPHVF8x6vQThi/72ZkGSwxfFkFpUZp5PXCjagk3vFw0NH8GbbtQeSAPfdgrcZunfJJgLt4SQ== 466 469 dependencies: 467 470 "@atproto/common" "^0.4.11" 468 471 "@atproto/crypto" "^0.4.4" 469 - "@atproto/lexicon" "^0.4.11" 470 - "@atproto/xrpc" "^0.7.0" 472 + "@atproto/lexicon" "^0.4.12" 473 + "@atproto/xrpc" "^0.7.1" 471 474 cbor-x "^1.5.1" 472 475 express "^4.17.2" 473 476 http-errors "^2.0.0" ··· 477 480 ws "^8.12.0" 478 481 zod "^3.23.8" 479 482 480 - "@atproto/xrpc@^0.7.0": 481 - version "0.7.0" 482 - resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.7.0.tgz#7d1e497d682431fecd7085d7482e83d8a33821b0" 483 - integrity sha512-SfhP9dGx2qclaScFDb58Jnrmim5nk4geZXCqg6sB0I/KZhZEkr9iIx1hLCp+sxkIfEsmEJjeWO4B0rjUIJW5cw== 483 + "@atproto/xrpc@^0.7.1": 484 + version "0.7.1" 485 + resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.7.1.tgz#51a8fc131eb21bd1229129d0a46384accc50ad65" 486 + integrity sha512-ANHEzlskYlMEdH18m+Itp3a8d0pEJao2qoDybDoMupTnoeNkya4VKIaOgAi6ERQnqatBBZyn9asW+7rJmSt/8g== 484 487 dependencies: 485 - "@atproto/lexicon" "^0.4.11" 488 + "@atproto/lexicon" "^0.4.12" 486 489 zod "^3.23.8" 487 490 488 491 "@aws-crypto/crc32@3.0.0":