about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--package.json2
-rw-r--r--src/components/Link.tsx20
-rw-r--r--src/components/dms/ReportDialog.tsx3
-rw-r--r--src/lib/constants.ts2
-rw-r--r--src/lib/statsig/events.ts7
-rw-r--r--src/screens/SignupQueued.tsx45
-rw-r--r--src/screens/Takendown.tsx263
-rw-r--r--src/state/session/agent.ts7
-rw-r--r--src/state/session/index.tsx2
-rw-r--r--src/view/shell/createNativeStackNavigatorWithAuth.tsx4
-rw-r--r--yarn.lock8
11 files changed, 322 insertions, 41 deletions
diff --git a/package.json b/package.json
index f38093caa..5009428ee 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/src/components/Link.tsx b/src/components/Link.tsx
index 50e741ea7..26cea5968 100644
--- a/src/components/Link.tsx
+++ b/src/components/Link.tsx
@@ -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,
diff --git a/src/components/dms/ReportDialog.tsx b/src/components/dms/ReportDialog.tsx
index a67ac47f2..4f9bc23ca 100644
--- a/src/components/dms/ReportDialog.tsx
+++ b/src/components/dms/ReportDialog.tsx
@@ -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,
diff --git a/src/lib/constants.ts b/src/lib/constants.ts
index aa7ff2928..c03439f56 100644
--- a/src/lib/constants.ts
+++ b/src/lib/constants.ts
@@ -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')
 }
diff --git a/src/lib/statsig/events.ts b/src/lib/statsig/events.ts
index af759e94e..189153a10 100644
--- a/src/lib/statsig/events.ts
+++ b/src/lib/statsig/events.ts
@@ -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': {}
diff --git a/src/screens/SignupQueued.tsx b/src/screens/SignupQueued.tsx
index f1c36a69c..61c387875 100644
--- a/src/screens/SignupQueued.tsx
+++ b/src/screens/SignupQueued.tsx
@@ -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>
       )}
diff --git a/src/screens/Takendown.tsx b/src/screens/Takendown.tsx
new file mode 100644
index 000000000..5eb787e80
--- /dev/null
+++ b/src/screens/Takendown.tsx
@@ -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>
+  )
+}
diff --git a/src/state/session/agent.ts b/src/state/session/agent.ts
index 84c816d44..ba0c14c1a 100644
--- a/src/state/session/agent.ts
+++ b/src/state/session/agent.ts
@@ -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')
diff --git a/src/state/session/index.tsx b/src/state/session/index.tsx
index 48b258863..03a8a936a 100644
--- a/src/state/session/index.tsx
+++ b/src/state/session/index.tsx
@@ -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
diff --git a/src/view/shell/createNativeStackNavigatorWithAuth.tsx b/src/view/shell/createNativeStackNavigatorWithAuth.tsx
index 9bcb91b7a..35a46b427 100644
--- a/src/view/shell/createNativeStackNavigatorWithAuth.tsx
+++ b/src/view/shell/createNativeStackNavigatorWithAuth.tsx
@@ -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)} />
   }
diff --git a/yarn.lock b/yarn.lock
index 187ad5b0c..0c5a08c02 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -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"