Takendown state + in-app takedown appeals (#7566)

* takendown screen

* add form, move button inline

* expect type error

* display error

* disable submit if too long

* move around all the ctas

* typos, rm layoutanimation, fix link

* use REASONAPPEAL
This commit is contained in:
Samuel Newman
2025-01-23 22:33:28 +00:00
committed by GitHub
parent 084d11c63a
commit f1d120d416
11 changed files with 322 additions and 41 deletions
+1 -1
View File
@@ -54,7 +54,7 @@
"icons:optimize": "svgo -f ./assets/icons"
},
"dependencies": {
"@atproto/api": "^0.13.30",
"@atproto/api": "^0.13.31",
"@bitdrift/react-native": "^0.6.2",
"@braintree/sanitize-url": "^6.0.2",
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
+13 -7
View File
@@ -79,8 +79,10 @@ export function useLink({
onPress: outerOnPress,
onLongPress: outerOnLongPress,
shareOnLongPress,
overridePresentation,
}: BaseLinkProps & {
displayText: string
overridePresentation?: boolean
}) {
const navigation = useNavigationDeduped()
const {href} = useLinkProps<AllNavigatorParams>({
@@ -116,7 +118,7 @@ export function useLink({
})
} else {
if (isExternal) {
openLink(href)
openLink(href, overridePresentation)
} else {
const shouldOpenInNewTab = shouldClickOpenNewTab(e)
@@ -158,6 +160,7 @@ export function useLink({
closeModal,
action,
navigation,
overridePresentation,
],
)
@@ -254,12 +257,13 @@ export function Link({
export type InlineLinkProps = React.PropsWithChildren<
BaseLinkProps &
TextStyleProp &
Pick<TextProps, 'selectable' | 'numberOfLines'>
> &
Pick<ButtonProps, 'label' | 'accessibilityHint'> & {
disableUnderline?: boolean
title?: TextProps['title']
}
Pick<TextProps, 'selectable' | 'numberOfLines'> &
Pick<ButtonProps, 'label' | 'accessibilityHint'> & {
disableUnderline?: boolean
title?: TextProps['title']
overridePresentation?: boolean
}
>
export function InlineLinkText({
children,
@@ -274,6 +278,7 @@ export function InlineLinkText({
label,
shareOnLongPress,
disableUnderline,
overridePresentation,
...rest
}: InlineLinkProps) {
const t = useTheme()
@@ -286,6 +291,7 @@ export function InlineLinkText({
onPress: outerOnPress,
onLongPress: outerOnLongPress,
shareOnLongPress,
overridePresentation,
})
const {
state: hovered,
+1 -2
View File
@@ -224,11 +224,10 @@ function SubmitStep({
multiline
defaultValue={details}
onChangeText={setDetails}
label="Text field"
label={_(msg`Text field`)}
style={{paddingRight: 60}}
numberOfLines={5}
/>
<View
style={[
a.absolute,
+2
View File
@@ -52,6 +52,8 @@ export const MAX_DM_GRAPHEME_LENGTH = 1000
// but increasing limit per user feedback
export const MAX_ALT_TEXT = 2000
export const MAX_REPORT_REASON_GRAPHEME_LENGTH = 2000
export function IS_TEST_USER(handle?: string) {
return handle && handle?.endsWith('.test')
}
+6 -1
View File
@@ -13,7 +13,12 @@ export type LogEvents = {
withPassword: boolean
}
'account:loggedOut': {
logContext: 'SwitchAccount' | 'Settings' | 'SignupQueued' | 'Deactivated'
logContext:
| 'SwitchAccount'
| 'Settings'
| 'SignupQueued'
| 'Deactivated'
| 'Takendown'
scope: 'current' | 'every'
}
'notifications:openApp': {}
+21 -24
View File
@@ -85,6 +85,21 @@ export function SignupQueued() {
</Button>
)
const logoutBtn = (
<Button
variant="ghost"
size="large"
color="primary"
label={_(msg`Log out`)}
onPress={() => logoutCurrentAccount('SignupQueued')}>
<ButtonText>
<Trans>Log out</Trans>
</ButtonText>
</Button>
)
const webLayout = isWeb && gtMobile
return (
<Modal
visible
@@ -108,7 +123,7 @@ export function SignupQueued() {
<Logo width={120} />
</View>
<Text style={[a.text_4xl, a.font_bold, a.pb_sm]}>
<Text style={[a.text_4xl, a.font_heavy, a.pb_sm]}>
<Trans>You're in line</Trans>
</Text>
<P style={[t.atoms.text_contrast_medium]}>
@@ -153,7 +168,7 @@ export function SignupQueued() {
</P>
</View>
{isWeb && gtMobile && (
{webLayout && (
<View
style={[
a.w_full,
@@ -162,15 +177,7 @@ export function SignupQueued() {
a.pt_5xl,
{paddingBottom: 200},
]}>
<Button
variant="ghost"
size="large"
label={_(msg`Log out`)}
onPress={() => logoutCurrentAccount('SignupQueued')}>
<ButtonText style={[{color: t.palette.primary_500}]}>
<Trans>Log out</Trans>
</ButtonText>
</Button>
{logoutBtn}
{checkBtn}
</View>
)}
@@ -178,27 +185,17 @@ export function SignupQueued() {
</View>
</ScrollView>
{(!isWeb || !gtMobile) && (
{!webLayout && (
<View
style={[
a.align_center,
t.atoms.bg,
gtMobile ? a.px_5xl : a.px_xl,
{
paddingBottom: Math.max(insets.bottom, a.pb_5xl.paddingBottom),
},
{paddingBottom: Math.max(insets.bottom, a.pb_5xl.paddingBottom)},
]}>
<View style={[a.w_full, a.gap_sm, {maxWidth: COL_WIDTH}]}>
{checkBtn}
<Button
variant="ghost"
size="large"
label={_(msg`Log out`)}
onPress={() => logoutCurrentAccount('SignupQueued')}>
<ButtonText style={[{color: t.palette.primary_500}]}>
<Trans>Log out</Trans>
</ButtonText>
</Button>
{logoutBtn}
</View>
</View>
)}
+263
View File
@@ -0,0 +1,263 @@
import {useMemo, useState} from 'react'
import {Modal, View} from 'react-native'
import {KeyboardAwareScrollView} from 'react-native-keyboard-controller'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {StatusBar} from 'expo-status-bar'
import {ComAtprotoAdminDefs, ComAtprotoModerationDefs} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useMutation} from '@tanstack/react-query'
import Graphemer from 'graphemer'
import {MAX_REPORT_REASON_GRAPHEME_LENGTH} from '#/lib/constants'
import {useEnableKeyboardController} from '#/lib/hooks/useEnableKeyboardController'
import {cleanError} from '#/lib/strings/errors'
import {isIOS, isWeb} from '#/platform/detection'
import {useAgent, useSession, useSessionApi} from '#/state/session'
import {CharProgress} from '#/view/com/composer/char-progress/CharProgress'
import {Logo} from '#/view/icons/Logo'
import {atoms as a, native, useBreakpoints, useTheme, web} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as TextField from '#/components/forms/TextField'
import {InlineLinkText} from '#/components/Link'
import {Loader} from '#/components/Loader'
import {P, Text} from '#/components/Typography'
const COL_WIDTH = 400
export function Takendown() {
const {_} = useLingui()
const t = useTheme()
const insets = useSafeAreaInsets()
const {gtMobile} = useBreakpoints()
const {currentAccount} = useSession()
const {logoutCurrentAccount} = useSessionApi()
const agent = useAgent()
const [isAppealling, setIsAppealling] = useState(false)
const [reason, setReason] = useState('')
const graphemer = useMemo(() => new Graphemer(), [])
const reasonGraphemeLength = useMemo(() => {
return graphemer.countGraphemes(reason)
}, [graphemer, reason])
const {
mutate: submitAppeal,
isPending,
isSuccess,
error,
} = useMutation({
mutationFn: async (appealText: string) => {
if (!currentAccount) throw new Error('No session')
await agent.com.atproto.moderation.createReport({
reasonType: ComAtprotoModerationDefs.REASONAPPEAL,
subject: {
$type: 'com.atproto.admin.defs#repoRef',
did: currentAccount.did,
} satisfies ComAtprotoAdminDefs.RepoRef,
reason: appealText,
})
},
onSuccess: () => setReason(''),
})
const primaryBtn =
isAppealling && !isSuccess ? (
<Button
variant="solid"
color="primary"
size="large"
label={_(msg`Submit appeal`)}
onPress={() => submitAppeal(reason)}
disabled={
isPending || reasonGraphemeLength > MAX_REPORT_REASON_GRAPHEME_LENGTH
}>
<ButtonText>
<Trans>Submit Appeal</Trans>
</ButtonText>
{isPending && <ButtonIcon icon={Loader} />}
</Button>
) : (
<Button
variant="solid"
size="large"
color="secondary_inverted"
label={_(msg`Log out`)}
onPress={() => logoutCurrentAccount('Takendown')}>
<ButtonText>
<Trans>Log Out</Trans>
</ButtonText>
</Button>
)
const secondaryBtn = isAppealling ? (
!isSuccess && (
<Button
variant="ghost"
size="large"
color="secondary"
label={_(msg`Cancel`)}
onPress={() => setIsAppealling(false)}>
<ButtonText>
<Trans>Cancel</Trans>
</ButtonText>
</Button>
)
) : (
<Button
variant="ghost"
size="large"
color="secondary"
label={_(msg`Appeal suspension`)}
onPress={() => setIsAppealling(true)}>
<ButtonText>
<Trans>Appeal Suspension</Trans>
</ButtonText>
</Button>
)
const webLayout = isWeb && gtMobile
useEnableKeyboardController(true)
return (
<Modal
visible
animationType={native('slide')}
presentationStyle="formSheet"
style={[web(a.util_screen_outer)]}>
{isIOS && <StatusBar style="light" />}
<KeyboardAwareScrollView style={[a.flex_1, t.atoms.bg]} centerContent>
<View
style={[
a.flex_row,
a.justify_center,
gtMobile ? a.pt_4xl : [a.px_xl, a.pt_4xl],
]}>
<View style={[a.flex_1, {maxWidth: COL_WIDTH, minHeight: COL_WIDTH}]}>
<View style={[a.pb_xl]}>
<Logo width={64} />
</View>
<Text style={[a.text_4xl, a.font_heavy, a.pb_md]}>
{isAppealling ? (
<Trans>Appeal suspension</Trans>
) : (
<Trans>Your account has been suspended</Trans>
)}
</Text>
{isAppealling ? (
<View style={[a.relative, a.w_full, a.mt_xl]}>
{isSuccess ? (
<P style={[t.atoms.text_contrast_medium, a.text_center]}>
<Trans>
Your appeal has been submitted. If your appeal succeeds,
you will receive an email.
</Trans>
</P>
) : (
<>
<TextField.LabelText>
<Trans>Reason for appeal</Trans>
</TextField.LabelText>
<TextField.Root
isInvalid={
reasonGraphemeLength >
MAX_REPORT_REASON_GRAPHEME_LENGTH || !!error
}>
<TextField.Input
label={_(msg`Reason for appeal`)}
defaultValue={reason}
onChangeText={setReason}
placeholder={_(msg`Why are you appealing?`)}
multiline
numberOfLines={5}
autoFocus
style={{paddingBottom: 40, minHeight: 150}}
maxLength={MAX_REPORT_REASON_GRAPHEME_LENGTH * 10}
/>
</TextField.Root>
<View
style={[
a.absolute,
a.flex_row,
a.align_center,
a.pr_md,
a.pb_sm,
{
bottom: 0,
right: 0,
},
]}>
<CharProgress
count={reasonGraphemeLength}
max={MAX_REPORT_REASON_GRAPHEME_LENGTH}
/>
</View>
</>
)}
{error && (
<Text
style={[
a.text_md,
a.leading_normal,
{color: t.palette.negative_500},
a.mt_lg,
]}>
{cleanError(error)}
</Text>
)}
</View>
) : (
<P style={[t.atoms.text_contrast_medium]}>
<Trans>
Your account was found to be in violation of the{' '}
<InlineLinkText
label={_(msg`Bluesky Social Terms of Service`)}
to="https://bsky.social/about/support/tos"
style={[a.text_md, a.leading_normal]}
overridePresentation>
Bluesky Social Terms of Service
</InlineLinkText>
. You have been sent an email outlining the specific violation
and suspension period, if applicable. You can appeal this
decision if you believe it was made in error.
</Trans>
</P>
)}
{webLayout && (
<View
style={[
a.w_full,
a.flex_row,
a.justify_between,
a.pt_5xl,
{paddingBottom: 200},
]}>
{secondaryBtn}
{primaryBtn}
</View>
)}
</View>
</View>
</KeyboardAwareScrollView>
{!webLayout && (
<View
style={[
a.align_center,
t.atoms.bg,
gtMobile ? a.px_5xl : a.px_xl,
{paddingBottom: Math.max(insets.bottom, a.pb_5xl.paddingBottom)},
]}>
<View style={[a.w_full, a.gap_sm, {maxWidth: COL_WIDTH}]}>
{primaryBtn}
{secondaryBtn}
</View>
</View>
)}
</Modal>
)
}
+6 -1
View File
@@ -83,7 +83,12 @@ export async function createAgentAndLogin(
) => void,
) {
const agent = new BskyAppAgent({service})
await agent.login({identifier, password, authFactorToken})
await agent.login({
identifier,
password,
authFactorToken,
allowTakendown: true,
})
const account = agentToSessionAccountOrThrow(agent)
const gates = tryFetchGates(account.did, 'prefer-fresh-gates')
+1 -1
View File
@@ -258,7 +258,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
],
)
// @ts-ignore
// @ts-expect-error window type is not declared, debug only
if (__DEV__ && isWeb) window.agent = state.currentAgentState.agent
const agent = state.currentAgentState.agent as BskyAppAgent
@@ -34,6 +34,7 @@ import {LoggedOut} from '#/view/com/auth/LoggedOut'
import {Deactivated} from '#/screens/Deactivated'
import {Onboarding} from '#/screens/Onboarding'
import {SignupQueued} from '#/screens/SignupQueued'
import {Takendown} from '#/screens/Takendown'
import {atoms as a} from '#/alf'
import {BottomBarWeb} from './bottom-bar/BottomBarWeb'
import {DesktopLeftNav} from './desktop/LeftNav'
@@ -107,6 +108,9 @@ function NativeStackNavigator({
if (hasSession && currentAccount?.signupQueued) {
return <SignupQueued />
}
if (hasSession && currentAccount?.status === 'takendown') {
return <Takendown />
}
if (showLoggedOut) {
return <LoggedOut onDismiss={() => setShowLoggedOut(false)} />
}
+4 -4
View File
@@ -72,10 +72,10 @@
tlds "^1.234.0"
zod "^3.23.8"
"@atproto/api@^0.13.30":
version "0.13.30"
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.13.30.tgz#073165003303995d0b6b7dfc24dafb8a58a1db6f"
integrity sha512-U+3XUACcCuoEvszh48vnzZITr1D7xZ8yz3EqjadYtV+zb3KjBmGroa50eaSRqHyeaDUZF38knumHPyUe9tTuqg==
"@atproto/api@^0.13.31":
version "0.13.31"
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.13.31.tgz#23ca2c9118eefddf6e0206f759e56b6726b68483"
integrity sha512-i2cUQuwe+3j8rgPJj4YWRjSQeJunGqJ3IzesnvbODjjZh3IS9jB80BZ/pTe/AvNg6JCBbqeWJjWDVKeFHaiZAw==
dependencies:
"@atproto/common-web" "^0.3.2"
"@atproto/lexicon" "^0.4.5"