diff options
Diffstat (limited to 'src/components')
16 files changed, 1357 insertions, 20 deletions
diff --git a/src/components/LanguageSelect.tsx b/src/components/LanguageSelect.tsx new file mode 100644 index 000000000..2ad3949ae --- /dev/null +++ b/src/components/LanguageSelect.tsx @@ -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> + ) +} diff --git a/src/components/PostControls/ShareMenu/ShareMenuItems.tsx b/src/components/PostControls/ShareMenu/ShareMenuItems.tsx index 1c04f3174..03b113708 100644 --- a/src/components/PostControls/ShareMenu/ShareMenuItems.tsx +++ b/src/components/PostControls/ShareMenu/ShareMenuItems.tsx @@ -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} /> diff --git a/src/components/PostControls/ShareMenu/ShareMenuItems.web.tsx b/src/components/PostControls/ShareMenu/ShareMenuItems.web.tsx index 8d52a2fdf..d074cdcf0 100644 --- a/src/components/PostControls/ShareMenu/ShareMenuItems.web.tsx +++ b/src/components/PostControls/ShareMenu/ShareMenuItems.web.tsx @@ -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`)} diff --git a/src/components/ageAssurance/AgeAssuranceAccountCard.tsx b/src/components/ageAssurance/AgeAssuranceAccountCard.tsx new file mode 100644 index 000000000..530e43d44 --- /dev/null +++ b/src/components/ageAssurance/AgeAssuranceAccountCard.tsx @@ -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> + </> + ) +} diff --git a/src/components/ageAssurance/AgeAssuranceAdmonition.tsx b/src/components/ageAssurance/AgeAssuranceAdmonition.tsx new file mode 100644 index 000000000..d140b7873 --- /dev/null +++ b/src/components/ageAssurance/AgeAssuranceAdmonition.tsx @@ -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> + </> + ) +} diff --git a/src/components/ageAssurance/AgeAssuranceAppealDialog.tsx b/src/components/ageAssurance/AgeAssuranceAppealDialog.tsx new file mode 100644 index 000000000..166f6c26d --- /dev/null +++ b/src/components/ageAssurance/AgeAssuranceAppealDialog.tsx @@ -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> + ) +} diff --git a/src/components/ageAssurance/AgeAssuranceBadge.tsx b/src/components/ageAssurance/AgeAssuranceBadge.tsx new file mode 100644 index 000000000..030e30529 --- /dev/null +++ b/src/components/ageAssurance/AgeAssuranceBadge.tsx @@ -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> + ) +} diff --git a/src/components/ageAssurance/AgeAssuranceDismissibleHeaderButton.tsx b/src/components/ageAssurance/AgeAssuranceDismissibleHeaderButton.tsx new file mode 100644 index 000000000..b6505fb0e --- /dev/null +++ b/src/components/ageAssurance/AgeAssuranceDismissibleHeaderButton.tsx @@ -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> + ) +} diff --git a/src/components/ageAssurance/AgeAssuranceDismissibleNotice.tsx b/src/components/ageAssurance/AgeAssuranceDismissibleNotice.tsx new file mode 100644 index 000000000..30e2fbec4 --- /dev/null +++ b/src/components/ageAssurance/AgeAssuranceDismissibleNotice.tsx @@ -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> + ) +} diff --git a/src/components/ageAssurance/AgeAssuranceInitDialog.tsx b/src/components/ageAssurance/AgeAssuranceInitDialog.tsx new file mode 100644 index 000000000..ad13cc1c2 --- /dev/null +++ b/src/components/ageAssurance/AgeAssuranceInitDialog.tsx @@ -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 + } +} diff --git a/src/components/ageAssurance/AgeAssuranceRedirectDialog.tsx b/src/components/ageAssurance/AgeAssuranceRedirectDialog.tsx new file mode 100644 index 000000000..41e706fee --- /dev/null +++ b/src/components/ageAssurance/AgeAssuranceRedirectDialog.tsx @@ -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 />} + </> + ) +} diff --git a/src/components/ageAssurance/AgeRestrictedScreen.tsx b/src/components/ageAssurance/AgeRestrictedScreen.tsx new file mode 100644 index 000000000..2a9882415 --- /dev/null +++ b/src/components/ageAssurance/AgeRestrictedScreen.tsx @@ -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> + ) +} diff --git a/src/components/ageAssurance/const.ts b/src/components/ageAssurance/const.ts new file mode 100644 index 000000000..35f96e841 --- /dev/null +++ b/src/components/ageAssurance/const.ts @@ -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'}, +] diff --git a/src/components/ageAssurance/useAgeAssuranceCopy.ts b/src/components/ageAssurance/useAgeAssuranceCopy.ts new file mode 100644 index 000000000..045806994 --- /dev/null +++ b/src/components/ageAssurance/useAgeAssuranceCopy.ts @@ -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.`, + ), + } + }, [_]) +} diff --git a/src/components/dialogs/Context.tsx b/src/components/dialogs/Context.tsx index 1ee4d2739..8c700cafe 100644 --- a/src/components/dialogs/Context.tsx +++ b/src/components/dialogs/Context.tsx @@ -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, ], ) diff --git a/src/components/moderation/LabelPreference.tsx b/src/components/moderation/LabelPreference.tsx index a951e5abf..edbb12d0c 100644 --- a/src/components/moderation/LabelPreference.tsx +++ b/src/components/moderation/LabelPreference.tsx @@ -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> ) } |