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