about summary refs log tree commit diff
path: root/src/view/com/modals
diff options
context:
space:
mode:
Diffstat (limited to 'src/view/com/modals')
-rw-r--r--src/view/com/modals/ChangeHandle.tsx4
-rw-r--r--src/view/com/modals/InviteCodes.tsx6
-rw-r--r--src/view/com/modals/ListAddRemoveUser.tsx65
-rw-r--r--src/view/com/modals/Modal.tsx42
-rw-r--r--src/view/com/modals/Modal.web.tsx15
-rw-r--r--src/view/com/modals/ModerationDetails.tsx105
-rw-r--r--src/view/com/modals/ProfilePreview.tsx91
-rw-r--r--src/view/com/modals/SelfLabel.tsx191
-rw-r--r--src/view/com/modals/report/Modal.tsx (renamed from src/view/com/modals/report/ReportPost.tsx)165
-rw-r--r--src/view/com/modals/report/ReasonOptions.tsx123
-rw-r--r--src/view/com/modals/report/ReportAccount.tsx197
-rw-r--r--src/view/com/modals/report/types.ts8
12 files changed, 617 insertions, 395 deletions
diff --git a/src/view/com/modals/ChangeHandle.tsx b/src/view/com/modals/ChangeHandle.tsx
index a6010906c..0b9707622 100644
--- a/src/view/com/modals/ChangeHandle.tsx
+++ b/src/view/com/modals/ChangeHandle.tsx
@@ -493,7 +493,9 @@ function CustomHandleForm({
           <ActivityIndicator color="white" />
         ) : (
           <Text type="xl-medium" style={[s.white, s.textCenter]}>
-            {canSave ? `Update to ${handle}` : 'Verify DNS Record'}
+            {canSave
+              ? `Update to ${handle}`
+              : `Verify ${isDNSForm ? 'DNS Record' : 'Text File'}`}
           </Text>
         )}
       </Button>
diff --git a/src/view/com/modals/InviteCodes.tsx b/src/view/com/modals/InviteCodes.tsx
index b3fe9dd3f..d46579f09 100644
--- a/src/view/com/modals/InviteCodes.tsx
+++ b/src/view/com/modals/InviteCodes.tsx
@@ -53,11 +53,7 @@ export function Component({}: {}) {
         Invite a Friend
       </Text>
       <Text type="lg" style={[styles.description, pal.text]}>
-        Send these invites to your friends so they can create an account. Each
-        code works once!
-      </Text>
-      <Text type="sm" style={[styles.description, pal.textLight]}>
-        (You'll receive one invite code every two weeks.)
+        Each code works once. You'll receive more invite codes periodically.
       </Text>
       <ScrollView style={[styles.scrollContainer, pal.border]}>
         {store.me.invites.map((invite, i) => (
diff --git a/src/view/com/modals/ListAddRemoveUser.tsx b/src/view/com/modals/ListAddRemoveUser.tsx
index 0f001f911..bfb7e4dc0 100644
--- a/src/view/com/modals/ListAddRemoveUser.tsx
+++ b/src/view/com/modals/ListAddRemoveUser.tsx
@@ -1,6 +1,6 @@
 import React, {useCallback} from 'react'
 import {observer} from 'mobx-react-lite'
-import {Pressable, StyleSheet, View} from 'react-native'
+import {Pressable, StyleSheet, View, ActivityIndicator} from 'react-native'
 import {AppBskyGraphDefs as GraphDefs} from '@atproto/api'
 import {
   FontAwesomeIcon,
@@ -42,6 +42,7 @@ export const Component = observer(
       string[]
     >([])
     const [selected, setSelected] = React.useState<string[]>([])
+    const [membershipsLoaded, setMembershipsLoaded] = React.useState(false)
 
     const listsList: ListsListModel = React.useMemo(
       () => new ListsListModel(store, store.me.did),
@@ -58,12 +59,13 @@ export const Component = observer(
           const ids = memberships.memberships.map(m => m.value.list)
           setOriginalSelections(ids)
           setSelected(ids)
+          setMembershipsLoaded(true)
         },
         err => {
           store.log.error('Failed to fetch memberships', {err})
         },
       )
-    }, [memberships, listsList, store, setSelected])
+    }, [memberships, listsList, store, setSelected, setMembershipsLoaded])
 
     const onPressCancel = useCallback(() => {
       store.shell.closeModal()
@@ -107,11 +109,16 @@ export const Component = observer(
         return (
           <Pressable
             testID={`toggleBtn-${list.name}`}
-            style={[styles.listItem, pal.border]}
+            style={[
+              styles.listItem,
+              pal.border,
+              {opacity: membershipsLoaded ? 1 : 0.5},
+            ]}
             accessibilityLabel={`${isSelected ? 'Remove from' : 'Add to'} ${
               list.name
             }`}
             accessibilityHint=""
+            disabled={!membershipsLoaded}
             onPress={() => onToggleSelected(list.uri)}>
             <View style={styles.listItemAvi}>
               <UserAvatar size={40} avatar={list.avatar} />
@@ -132,23 +139,33 @@ export const Component = observer(
                   : sanitizeHandle(list.creator.handle, '@')}
               </Text>
             </View>
-            <View
-              style={
-                isSelected
-                  ? [styles.checkbox, palPrimary.border, palPrimary.view]
-                  : [styles.checkbox, pal.borderDark]
-              }>
-              {isSelected && (
-                <FontAwesomeIcon
-                  icon="check"
-                  style={palInverted.text as FontAwesomeIconStyle}
-                />
-              )}
-            </View>
+            {membershipsLoaded && (
+              <View
+                style={
+                  isSelected
+                    ? [styles.checkbox, palPrimary.border, palPrimary.view]
+                    : [styles.checkbox, pal.borderDark]
+                }>
+                {isSelected && (
+                  <FontAwesomeIcon
+                    icon="check"
+                    style={palInverted.text as FontAwesomeIconStyle}
+                  />
+                )}
+              </View>
+            )}
           </Pressable>
         )
       },
-      [pal, palPrimary, palInverted, onToggleSelected, selected, store.me.did],
+      [
+        pal,
+        palPrimary,
+        palInverted,
+        onToggleSelected,
+        selected,
+        store.me.did,
+        membershipsLoaded,
+      ],
     )
 
     const renderEmptyState = React.useCallback(() => {
@@ -200,6 +217,12 @@ export const Component = observer(
               label="Save Changes"
             />
           )}
+
+          {(listsList.isLoading || !membershipsLoaded) && (
+            <View style={styles.loadingContainer}>
+              <ActivityIndicator />
+            </View>
+          )}
         </View>
       </View>
     )
@@ -221,6 +244,7 @@ const styles = StyleSheet.create({
     borderTopWidth: 1,
   },
   btns: {
+    position: 'relative',
     flexDirection: 'row',
     alignItems: 'center',
     justifyContent: 'center',
@@ -263,4 +287,11 @@ const styles = StyleSheet.create({
     borderRadius: 6,
     marginRight: 8,
   },
+  loadingContainer: {
+    position: 'absolute',
+    top: 10,
+    right: 0,
+    bottom: 0,
+    justifyContent: 'center',
+  },
 })
diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx
index 525df7ba1..efd06412d 100644
--- a/src/view/com/modals/Modal.tsx
+++ b/src/view/com/modals/Modal.tsx
@@ -6,18 +6,20 @@ import BottomSheet from '@gorhom/bottom-sheet'
 import {useStores} from 'state/index'
 import {createCustomBackdrop} from '../util/BottomSheetCustomBackdrop'
 import {usePalette} from 'lib/hooks/usePalette'
+import {navigate} from '../../../Navigation'
+import once from 'lodash.once'
 
 import * as ConfirmModal from './Confirm'
 import * as EditProfileModal from './EditProfile'
 import * as ProfilePreviewModal from './ProfilePreview'
 import * as ServerInputModal from './ServerInput'
-import * as ReportPostModal from './report/ReportPost'
 import * as RepostModal from './Repost'
+import * as SelfLabelModal from './SelfLabel'
 import * as CreateOrEditMuteListModal from './CreateOrEditMuteList'
 import * as ListAddRemoveUserModal from './ListAddRemoveUser'
 import * as AltImageModal from './AltImage'
 import * as EditImageModal from './AltImage'
-import * as ReportAccountModal from './report/ReportAccount'
+import * as ReportModal from './report/Modal'
 import * as DeleteAccountModal from './DeleteAccount'
 import * as ChangeHandleModal from './ChangeHandle'
 import * as WaitlistModal from './Waitlist'
@@ -28,6 +30,7 @@ import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguages
 import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings'
 import * as PreferencesHomeFeed from './PreferencesHomeFeed'
 import * as OnboardingModal from './OnboardingModal'
+import * as ModerationDetailsModal from './ModerationDetails'
 
 const DEFAULT_SNAPPOINTS = ['90%']
 
@@ -35,9 +38,25 @@ export const ModalsContainer = observer(function ModalsContainer() {
   const store = useStores()
   const bottomSheetRef = useRef<BottomSheet>(null)
   const pal = usePalette('default')
+
+  const activeModal =
+    store.shell.activeModals[store.shell.activeModals.length - 1]
+
+  const navigateOnce = once(navigate)
+
+  const onBottomSheetAnimate = (fromIndex: number, toIndex: number) => {
+    if (activeModal?.name === 'profile-preview' && toIndex === 1) {
+      // begin loading the profile screen behind the scenes
+      navigateOnce('Profile', {name: activeModal.did})
+    }
+  }
   const onBottomSheetChange = (snapPoint: number) => {
     if (snapPoint === -1) {
       store.shell.closeModal()
+    } else if (activeModal?.name === 'profile-preview' && snapPoint === 1) {
+      // ensure we navigate to Profile and close the modal
+      navigateOnce('Profile', {name: activeModal.did})
+      store.shell.closeModal()
     }
   }
   const onClose = () => {
@@ -45,9 +64,6 @@ export const ModalsContainer = observer(function ModalsContainer() {
     store.shell.closeModal()
   }
 
-  const activeModal =
-    store.shell.activeModals[store.shell.activeModals.length - 1]
-
   useEffect(() => {
     if (store.shell.isModalActive) {
       bottomSheetRef.current?.expand()
@@ -70,12 +86,9 @@ export const ModalsContainer = observer(function ModalsContainer() {
   } else if (activeModal?.name === 'server-input') {
     snapPoints = ServerInputModal.snapPoints
     element = <ServerInputModal.Component {...activeModal} />
-  } else if (activeModal?.name === 'report-post') {
-    snapPoints = ReportPostModal.snapPoints
-    element = <ReportPostModal.Component {...activeModal} />
-  } else if (activeModal?.name === 'report-account') {
-    snapPoints = ReportAccountModal.snapPoints
-    element = <ReportAccountModal.Component {...activeModal} />
+  } else if (activeModal?.name === 'report') {
+    snapPoints = ReportModal.snapPoints
+    element = <ReportModal.Component {...activeModal} />
   } else if (activeModal?.name === 'create-or-edit-mute-list') {
     snapPoints = CreateOrEditMuteListModal.snapPoints
     element = <CreateOrEditMuteListModal.Component {...activeModal} />
@@ -88,6 +101,9 @@ export const ModalsContainer = observer(function ModalsContainer() {
   } else if (activeModal?.name === 'repost') {
     snapPoints = RepostModal.snapPoints
     element = <RepostModal.Component {...activeModal} />
+  } else if (activeModal?.name === 'self-label') {
+    snapPoints = SelfLabelModal.snapPoints
+    element = <SelfLabelModal.Component {...activeModal} />
   } else if (activeModal?.name === 'alt-text-image') {
     snapPoints = AltImageModal.snapPoints
     element = <AltImageModal.Component {...activeModal} />
@@ -121,6 +137,9 @@ export const ModalsContainer = observer(function ModalsContainer() {
   } else if (activeModal?.name === 'onboarding') {
     snapPoints = OnboardingModal.snapPoints
     element = <OnboardingModal.Component />
+  } else if (activeModal?.name === 'moderation-details') {
+    snapPoints = ModerationDetailsModal.snapPoints
+    element = <ModerationDetailsModal.Component {...activeModal} />
   } else {
     return null
   }
@@ -146,6 +165,7 @@ export const ModalsContainer = observer(function ModalsContainer() {
       }
       handleIndicatorStyle={{backgroundColor: pal.text.color}}
       handleStyle={[styles.handle, pal.view]}
+      onAnimate={onBottomSheetAnimate}
       onChange={onBottomSheetChange}>
       {element}
     </BottomSheet>
diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx
index 39cdbd868..0e28b1618 100644
--- a/src/view/com/modals/Modal.web.tsx
+++ b/src/view/com/modals/Modal.web.tsx
@@ -10,12 +10,12 @@ import * as ConfirmModal from './Confirm'
 import * as EditProfileModal from './EditProfile'
 import * as ProfilePreviewModal from './ProfilePreview'
 import * as ServerInputModal from './ServerInput'
-import * as ReportPostModal from './report/ReportPost'
-import * as ReportAccountModal from './report/ReportAccount'
+import * as ReportModal from './report/Modal'
 import * as CreateOrEditMuteListModal from './CreateOrEditMuteList'
 import * as ListAddRemoveUserModal from './ListAddRemoveUser'
 import * as DeleteAccountModal from './DeleteAccount'
 import * as RepostModal from './Repost'
+import * as SelfLabelModal from './SelfLabel'
 import * as CropImageModal from './crop-image/CropImage.web'
 import * as AltTextImageModal from './AltImage'
 import * as EditImageModal from './EditImage'
@@ -27,6 +27,7 @@ import * as ContentFilteringSettingsModal from './ContentFilteringSettings'
 import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings'
 import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings'
 import * as OnboardingModal from './OnboardingModal'
+import * as ModerationDetailsModal from './ModerationDetails'
 
 import * as PreferencesHomeFeed from './PreferencesHomeFeed'
 
@@ -74,10 +75,8 @@ function Modal({modal}: {modal: ModalIface}) {
     element = <ProfilePreviewModal.Component {...modal} />
   } else if (modal.name === 'server-input') {
     element = <ServerInputModal.Component {...modal} />
-  } else if (modal.name === 'report-post') {
-    element = <ReportPostModal.Component {...modal} />
-  } else if (modal.name === 'report-account') {
-    element = <ReportAccountModal.Component {...modal} />
+  } else if (modal.name === 'report') {
+    element = <ReportModal.Component {...modal} />
   } else if (modal.name === 'create-or-edit-mute-list') {
     element = <CreateOrEditMuteListModal.Component {...modal} />
   } else if (modal.name === 'list-add-remove-user') {
@@ -88,6 +87,8 @@ function Modal({modal}: {modal: ModalIface}) {
     element = <DeleteAccountModal.Component />
   } else if (modal.name === 'repost') {
     element = <RepostModal.Component {...modal} />
+  } else if (modal.name === 'self-label') {
+    element = <SelfLabelModal.Component {...modal} />
   } else if (modal.name === 'change-handle') {
     element = <ChangeHandleModal.Component {...modal} />
   } else if (modal.name === 'waitlist') {
@@ -110,6 +111,8 @@ function Modal({modal}: {modal: ModalIface}) {
     element = <PreferencesHomeFeed.Component />
   } else if (modal.name === 'onboarding') {
     element = <OnboardingModal.Component />
+  } else if (modal.name === 'moderation-details') {
+    element = <ModerationDetailsModal.Component {...modal} />
   } else {
     return null
   }
diff --git a/src/view/com/modals/ModerationDetails.tsx b/src/view/com/modals/ModerationDetails.tsx
new file mode 100644
index 000000000..b0e68e61b
--- /dev/null
+++ b/src/view/com/modals/ModerationDetails.tsx
@@ -0,0 +1,105 @@
+import React from 'react'
+import {StyleSheet, View} from 'react-native'
+import {ModerationUI} from '@atproto/api'
+import {useStores} from 'state/index'
+import {s} from 'lib/styles'
+import {Text} from '../util/text/Text'
+import {TextLink} from '../util/Link'
+import {usePalette} from 'lib/hooks/usePalette'
+import {isDesktopWeb} from 'platform/detection'
+import {listUriToHref} from 'lib/strings/url-helpers'
+import {Button} from '../util/forms/Button'
+
+export const snapPoints = [300]
+
+export function Component({
+  context,
+  moderation,
+}: {
+  context: 'account' | 'content'
+  moderation: ModerationUI
+}) {
+  const store = useStores()
+  const pal = usePalette('default')
+
+  let name
+  let description
+  if (!moderation.cause) {
+    name = 'Content Warning'
+    description =
+      'Moderator has chosen to set a general warning on the content.'
+  } else if (moderation.cause.type === 'blocking') {
+    name = 'User Blocked'
+    description = 'You have blocked this user. You cannot view their content.'
+  } else if (moderation.cause.type === 'blocked-by') {
+    name = 'User Blocks You'
+    description = 'This user has blocked you. You cannot view their content.'
+  } else if (moderation.cause.type === 'block-other') {
+    name = 'Content Not Available'
+    description =
+      'This content is not available because one of the users involved has blocked the other.'
+  } else if (moderation.cause.type === 'muted') {
+    if (moderation.cause.source.type === 'list') {
+      const list = moderation.cause.source.list
+      name = <>Account Muted by List</>
+      description = (
+        <>
+          This user is included the{' '}
+          <TextLink
+            type="2xl"
+            href={listUriToHref(list.uri)}
+            text={list.name}
+            style={pal.link}
+          />{' '}
+          list which you have muted.
+        </>
+      )
+    } else {
+      name = 'Account Muted'
+      description = 'You have muted this user.'
+    }
+  } else {
+    name = moderation.cause.labelDef.strings[context].en.name
+    description = moderation.cause.labelDef.strings[context].en.description
+  }
+
+  return (
+    <View testID="moderationDetailsModal" style={[styles.container, pal.view]}>
+      <Text type="title-xl" style={[pal.text, styles.title]}>
+        {name}
+      </Text>
+      <Text type="2xl" style={[pal.text, styles.description]}>
+        {description}
+      </Text>
+      <View style={s.flex1} />
+      <Button
+        type="primary"
+        style={styles.btn}
+        onPress={() => store.shell.closeModal()}>
+        <Text type="button-lg" style={[pal.textLight, s.textCenter, s.white]}>
+          Okay
+        </Text>
+      </Button>
+    </View>
+  )
+}
+
+const styles = StyleSheet.create({
+  container: {
+    flex: 1,
+    paddingHorizontal: isDesktopWeb ? 0 : 14,
+  },
+  title: {
+    textAlign: 'center',
+    fontWeight: 'bold',
+    marginBottom: 12,
+  },
+  description: {
+    textAlign: 'center',
+  },
+  btn: {
+    paddingVertical: 14,
+    marginTop: isDesktopWeb ? 40 : 0,
+    marginBottom: isDesktopWeb ? 0 : 40,
+  },
+})
diff --git a/src/view/com/modals/ProfilePreview.tsx b/src/view/com/modals/ProfilePreview.tsx
index d3267644b..4efe81225 100644
--- a/src/view/com/modals/ProfilePreview.tsx
+++ b/src/view/com/modals/ProfilePreview.tsx
@@ -1,63 +1,56 @@
-import React, {useState, useEffect, useCallback} from 'react'
-import {StyleSheet, View} from 'react-native'
+import React, {useState, useEffect} from 'react'
+import {ActivityIndicator, StyleSheet, View} from 'react-native'
 import {observer} from 'mobx-react-lite'
-import {useNavigation, StackActions} from '@react-navigation/native'
-import {Text} from '../util/text/Text'
+import {ThemedText} from '../util/text/ThemedText'
 import {useStores} from 'state/index'
 import {ProfileModel} from 'state/models/content/profile'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useAnalytics} from 'lib/analytics/analytics'
 import {ProfileHeader} from '../profile/ProfileHeader'
-import {Button} from '../util/forms/Button'
-import {NavigationProp} from 'lib/routes/types'
+import {InfoCircleIcon} from 'lib/icons'
+import {useNavigationState} from '@react-navigation/native'
+import {isIOS} from 'platform/detection'
+import {s} from 'lib/styles'
 
-export const snapPoints = [560]
+export const snapPoints = [520, '100%']
 
 export const Component = observer(({did}: {did: string}) => {
   const store = useStores()
   const pal = usePalette('default')
-  const palInverted = usePalette('inverted')
-  const navigation = useNavigation<NavigationProp>()
   const [model] = useState(new ProfileModel(store, {actor: did}))
   const {screen} = useAnalytics()
 
+  // track the navigator state to detect if a page-load occurred
+  const navState = useNavigationState(s => s)
+  const [initNavState] = useState(navState)
+  const isLoading = initNavState !== navState
+
   useEffect(() => {
     screen('Profile:Preview')
     model.setup()
   }, [model, screen])
 
-  const onPressViewProfile = useCallback(() => {
-    navigation.dispatch(StackActions.push('Profile', {name: model.handle}))
-    store.shell.closeModal()
-  }, [navigation, store, model])
-
   return (
-    <View style={pal.view}>
-      <View style={styles.headerWrapper}>
+    <View style={[pal.view, s.flex1]}>
+      <View
+        style={[
+          styles.headerWrapper,
+          isLoading && isIOS && styles.headerPositionAdjust,
+        ]}>
         <ProfileHeader view={model} hideBackButton onRefreshAll={() => {}} />
       </View>
-      <View style={[styles.buttonsContainer, pal.view]}>
-        <View style={styles.buttons}>
-          <Button
-            type="inverted"
-            style={[styles.button, styles.buttonWide]}
-            onPress={onPressViewProfile}
-            accessibilityLabel="View profile"
-            accessibilityHint="">
-            <Text type="button-lg" style={palInverted.text}>
-              View Profile
-            </Text>
-          </Button>
-          <Button
-            type="default"
-            style={styles.button}
-            onPress={() => store.shell.closeModal()}
-            accessibilityLabel="Close this preview"
-            accessibilityHint="">
-            <Text type="button-lg" style={pal.text}>
-              Close
-            </Text>
-          </Button>
+      <View style={[styles.hintWrapper, pal.view]}>
+        <View style={styles.hint}>
+          {isLoading ? (
+            <ActivityIndicator />
+          ) : (
+            <>
+              <InfoCircleIcon size={21} style={pal.textLight} />
+              <ThemedText type="xl" fg="light">
+                Swipe up to see more
+              </ThemedText>
+            </>
+          )}
         </View>
       </View>
     </View>
@@ -68,22 +61,18 @@ const styles = StyleSheet.create({
   headerWrapper: {
     height: 440,
   },
-  buttonsContainer: {
-    height: 120,
+  headerPositionAdjust: {
+    // HACK align the header for the profilescreen transition -prf
+    paddingTop: 23,
   },
-  buttons: {
-    flexDirection: 'row',
-    gap: 8,
-    paddingHorizontal: 14,
-    paddingTop: 16,
+  hintWrapper: {
+    height: 80,
   },
-  button: {
-    flex: 2,
+  hint: {
     flexDirection: 'row',
     justifyContent: 'center',
-    paddingVertical: 12,
-  },
-  buttonWide: {
-    flex: 3,
+    gap: 8,
+    paddingHorizontal: 14,
+    borderRadius: 6,
   },
 })
diff --git a/src/view/com/modals/SelfLabel.tsx b/src/view/com/modals/SelfLabel.tsx
new file mode 100644
index 000000000..42863fd33
--- /dev/null
+++ b/src/view/com/modals/SelfLabel.tsx
@@ -0,0 +1,191 @@
+import React, {useState} from 'react'
+import {StyleSheet, TouchableOpacity, View} from 'react-native'
+import {observer} from 'mobx-react-lite'
+import {Text} from '../util/text/Text'
+import {useStores} from 'state/index'
+import {s, colors} from 'lib/styles'
+import {usePalette} from 'lib/hooks/usePalette'
+import {isDesktopWeb} from 'platform/detection'
+import {Button} from '../util/forms/Button'
+import {SelectableBtn} from '../util/forms/SelectableBtn'
+import {ScrollView} from 'view/com/modals/util'
+
+const ADULT_CONTENT_LABELS = ['sexual', 'nudity', 'porn']
+
+export const snapPoints = ['50%']
+
+export const Component = observer(function Component({
+  labels,
+  hasMedia,
+  onChange,
+}: {
+  labels: string[]
+  hasMedia: boolean
+  onChange: (labels: string[]) => void
+}) {
+  const pal = usePalette('default')
+  const store = useStores()
+  const [selected, setSelected] = useState(labels)
+
+  const toggleAdultLabel = (label: string) => {
+    const hadLabel = selected.includes(label)
+    const stripped = selected.filter(l => !ADULT_CONTENT_LABELS.includes(l))
+    const final = !hadLabel ? stripped.concat([label]) : stripped
+    setSelected(final)
+    onChange(final)
+  }
+
+  const removeAdultLabel = () => {
+    const final = selected.filter(l => !ADULT_CONTENT_LABELS.includes(l))
+    setSelected(final)
+    onChange(final)
+  }
+
+  const hasAdultSelection =
+    selected.includes('sexual') ||
+    selected.includes('nudity') ||
+    selected.includes('porn')
+  return (
+    <View testID="selfLabelModal" style={[pal.view, styles.container]}>
+      <View style={styles.titleSection}>
+        <Text type="title-lg" style={[pal.text, styles.title]}>
+          Add a content warning
+        </Text>
+      </View>
+
+      <ScrollView>
+        <View style={[styles.section, pal.border, {borderBottomWidth: 1}]}>
+          <View
+            style={{
+              flexDirection: 'row',
+              alignItems: 'center',
+              justifyContent: 'space-between',
+              paddingBottom: 8,
+            }}>
+            <Text type="title" style={pal.text}>
+              Adult Content
+            </Text>
+            {hasAdultSelection ? (
+              <Button
+                type="default-light"
+                onPress={removeAdultLabel}
+                style={{paddingTop: 0, paddingBottom: 0, paddingRight: 0}}>
+                <Text type="md" style={pal.link}>
+                  Remove
+                </Text>
+              </Button>
+            ) : null}
+          </View>
+          {hasMedia ? (
+            <>
+              <View style={s.flexRow}>
+                <SelectableBtn
+                  testID="sexualLabelBtn"
+                  selected={selected.includes('sexual')}
+                  left
+                  label="Suggestive"
+                  onSelect={() => toggleAdultLabel('sexual')}
+                  accessibilityHint=""
+                  style={s.flex1}
+                />
+                <SelectableBtn
+                  testID="nudityLabelBtn"
+                  selected={selected.includes('nudity')}
+                  label="Nudity"
+                  onSelect={() => toggleAdultLabel('nudity')}
+                  accessibilityHint=""
+                  style={s.flex1}
+                />
+                <SelectableBtn
+                  testID="pornLabelBtn"
+                  selected={selected.includes('porn')}
+                  label="Porn"
+                  right
+                  onSelect={() => toggleAdultLabel('porn')}
+                  accessibilityHint=""
+                  style={s.flex1}
+                />
+              </View>
+
+              <Text style={[pal.text, styles.adultExplainer]}>
+                {selected.includes('sexual') ? (
+                  <>Pictures meant for adults.</>
+                ) : selected.includes('nudity') ? (
+                  <>Artistic or non-erotic nudity.</>
+                ) : selected.includes('porn') ? (
+                  <>Sexual activity or erotic nudity.</>
+                ) : (
+                  <>If none are selected, suitable for all ages.</>
+                )}
+              </Text>
+            </>
+          ) : (
+            <View>
+              <Text style={[pal.textLight]}>
+                <Text type="md-bold" style={[pal.textLight]}>
+                  Not Applicable
+                </Text>
+                . This warning is only available for posts with media attached.
+              </Text>
+            </View>
+          )}
+        </View>
+      </ScrollView>
+
+      <View style={[styles.btnContainer, pal.borderDark]}>
+        <TouchableOpacity
+          testID="confirmBtn"
+          onPress={() => {
+            store.shell.closeModal()
+          }}
+          style={styles.btn}
+          accessibilityRole="button"
+          accessibilityLabel="Confirm"
+          accessibilityHint="">
+          <Text style={[s.white, s.bold, s.f18]}>Done</Text>
+        </TouchableOpacity>
+      </View>
+    </View>
+  )
+})
+
+const styles = StyleSheet.create({
+  container: {
+    flex: 1,
+    paddingBottom: isDesktopWeb ? 0 : 40,
+  },
+  titleSection: {
+    paddingTop: isDesktopWeb ? 0 : 4,
+    paddingBottom: isDesktopWeb ? 14 : 10,
+  },
+  title: {
+    textAlign: 'center',
+    fontWeight: '600',
+    marginBottom: 5,
+  },
+  description: {
+    textAlign: 'center',
+    paddingHorizontal: 32,
+  },
+  section: {
+    borderTopWidth: 1,
+    paddingVertical: 20,
+    paddingHorizontal: isDesktopWeb ? 0 : 20,
+  },
+  adultExplainer: {
+    paddingLeft: 5,
+    paddingTop: 10,
+  },
+  btn: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    justifyContent: 'center',
+    borderRadius: 32,
+    padding: 14,
+    backgroundColor: colors.blue3,
+  },
+  btnContainer: {
+    paddingTop: 20,
+    paddingHorizontal: 20,
+  },
+})
diff --git a/src/view/com/modals/report/ReportPost.tsx b/src/view/com/modals/report/Modal.tsx
index 34ec8c2f2..f386b110d 100644
--- a/src/view/com/modals/report/ReportPost.tsx
+++ b/src/view/com/modals/report/Modal.tsx
@@ -1,10 +1,9 @@
 import React, {useState, useMemo} from 'react'
 import {Linking, StyleSheet, TouchableOpacity, View} from 'react-native'
 import {ScrollView} from 'react-native-gesture-handler'
-import {ComAtprotoModerationDefs} from '@atproto/api'
+import {AtUri} from '@atproto/api'
 import {useStores} from 'state/index'
 import {s} from 'lib/styles'
-import {RadioGroup, RadioGroupItem} from '../../util/forms/RadioGroup'
 import {Text} from '../../util/text/Text'
 import * as Toast from '../../util/Toast'
 import {ErrorMessage} from '../../util/error/ErrorMessage'
@@ -12,25 +11,43 @@ import {cleanError} from 'lib/strings/errors'
 import {usePalette} from 'lib/hooks/usePalette'
 import {SendReportButton} from './SendReportButton'
 import {InputIssueDetails} from './InputIssueDetails'
+import {ReportReasonOptions} from './ReasonOptions'
+import {CollectionId} from './types'
 
 const DMCA_LINK = 'https://bsky.app/support/copyright'
 
 export const snapPoints = [575]
 
-export function Component({
-  postUri,
-  postCid,
-}: {
-  postUri: string
-  postCid: string
-}) {
+const CollectionNames = {
+  [CollectionId.FeedGenerator]: 'Feed',
+  [CollectionId.Profile]: 'Profile',
+  [CollectionId.List]: 'List',
+  [CollectionId.Post]: 'Post',
+}
+
+type ReportComponentProps =
+  | {
+      uri: string
+      cid: string
+    }
+  | {
+      did: string
+    }
+
+export function Component(content: ReportComponentProps) {
   const store = useStores()
   const pal = usePalette('default')
   const [isProcessing, setIsProcessing] = useState(false)
-  const [showTextInput, setShowTextInput] = useState(false)
+  const [showDetailsInput, setShowDetailsInput] = useState(false)
   const [error, setError] = useState<string>()
   const [issue, setIssue] = useState<string>()
   const [details, setDetails] = useState<string>()
+  const isAccountReport = 'did' in content
+  const subjectKey = isAccountReport ? content.did : content.uri
+  const atUri = useMemo(
+    () => (!isAccountReport ? new AtUri(subjectKey) : null),
+    [isAccountReport, subjectKey],
+  )
 
   const submitReport = async () => {
     setError('')
@@ -43,12 +60,14 @@ export function Component({
         Linking.openURL(DMCA_LINK)
         return
       }
+      const $type = !isAccountReport
+        ? 'com.atproto.repo.strongRef'
+        : 'com.atproto.admin.defs#repoRef'
       await store.agent.createModerationReport({
         reasonType: issue,
         subject: {
-          $type: 'com.atproto.repo.strongRef',
-          uri: postUri,
-          cid: postCid,
+          $type,
+          ...content,
         },
         reason: details,
       })
@@ -63,13 +82,13 @@ export function Component({
   }
 
   const goBack = () => {
-    setShowTextInput(false)
+    setShowDetailsInput(false)
   }
 
   return (
-    <ScrollView testID="reportPostModal" style={[s.flex1, pal.view]}>
+    <ScrollView testID="reportModal" style={[s.flex1, pal.view]}>
       <View style={styles.container}>
-        {showTextInput ? (
+        {showDetailsInput ? (
           <InputIssueDetails
             details={details}
             setDetails={setDetails}
@@ -79,12 +98,13 @@ export function Component({
           />
         ) : (
           <SelectIssue
-            setShowTextInput={setShowTextInput}
+            setShowDetailsInput={setShowDetailsInput}
             error={error}
             issue={issue}
             setIssue={setIssue}
             submitReport={submitReport}
             isProcessing={isProcessing}
+            atUri={atUri}
           />
         )}
       </View>
@@ -92,128 +112,59 @@ export function Component({
   )
 }
 
+// If no atUri is passed, that means the reporting collection is account
+const getCollectionNameForReport = (atUri: AtUri | null) => {
+  if (!atUri) return 'Account'
+  // Generic fallback for any collection being reported
+  return CollectionNames[atUri.collection as CollectionId] || 'Content'
+}
+
 const SelectIssue = ({
   error,
-  setShowTextInput,
+  setShowDetailsInput,
   issue,
   setIssue,
   submitReport,
   isProcessing,
+  atUri,
 }: {
   error: string | undefined
-  setShowTextInput: (v: boolean) => void
+  setShowDetailsInput: (v: boolean) => void
   issue: string | undefined
   setIssue: (v: string) => void
   submitReport: () => void
   isProcessing: boolean
+  atUri: AtUri | null
 }) => {
   const pal = usePalette('default')
-  const ITEMS: RadioGroupItem[] = useMemo(
-    () => [
-      {
-        key: ComAtprotoModerationDefs.REASONSPAM,
-        label: (
-          <View>
-            <Text style={pal.text} type="md-bold">
-              Spam
-            </Text>
-            <Text style={pal.textLight}>Excessive mentions or replies</Text>
-          </View>
-        ),
-      },
-      {
-        key: ComAtprotoModerationDefs.REASONSEXUAL,
-        label: (
-          <View>
-            <Text style={pal.text} type="md-bold">
-              Unwanted Sexual Content
-            </Text>
-            <Text style={pal.textLight}>
-              Nudity or pornography not labeled as such
-            </Text>
-          </View>
-        ),
-      },
-      {
-        key: '__copyright__',
-        label: (
-          <View>
-            <Text style={pal.text} type="md-bold">
-              Copyright Violation
-            </Text>
-            <Text style={pal.textLight}>Contains copyrighted material</Text>
-          </View>
-        ),
-      },
-      {
-        key: ComAtprotoModerationDefs.REASONRUDE,
-        label: (
-          <View>
-            <Text style={pal.text} type="md-bold">
-              Anti-Social Behavior
-            </Text>
-            <Text style={pal.textLight}>
-              Harassment, trolling, or intolerance
-            </Text>
-          </View>
-        ),
-      },
-      {
-        key: ComAtprotoModerationDefs.REASONVIOLATION,
-        label: (
-          <View>
-            <Text style={pal.text} type="md-bold">
-              Illegal and Urgent
-            </Text>
-            <Text style={pal.textLight}>
-              Glaring violations of law or terms of service
-            </Text>
-          </View>
-        ),
-      },
-      {
-        key: ComAtprotoModerationDefs.REASONOTHER,
-        label: (
-          <View>
-            <Text style={pal.text} type="md-bold">
-              Other
-            </Text>
-            <Text style={pal.textLight}>
-              An issue not included in these options
-            </Text>
-          </View>
-        ),
-      },
-    ],
-    [pal],
-  )
-
+  const collectionName = getCollectionNameForReport(atUri)
   const onSelectIssue = (v: string) => setIssue(v)
   const goToDetails = () => {
     if (issue === '__copyright__') {
       Linking.openURL(DMCA_LINK)
       return
     }
-    setShowTextInput(true)
+    setShowDetailsInput(true)
   }
 
   return (
     <>
-      <Text style={[pal.text, styles.title]}>Report post</Text>
+      <Text style={[pal.text, styles.title]}>Report {collectionName}</Text>
       <Text style={[pal.textLight, styles.description]}>
-        What is the issue with this post?
+        What is the issue with this {collectionName}?
       </Text>
-      <RadioGroup
-        testID="reportPostRadios"
-        items={ITEMS}
-        onSelect={onSelectIssue}
+      <ReportReasonOptions
+        atUri={atUri}
+        selectedIssue={issue}
+        onSelectIssue={onSelectIssue}
       />
       {error ? (
         <View style={s.mt10}>
           <ErrorMessage message={error} />
         </View>
       ) : undefined}
-      {issue ? (
+      {/* If no atUri is present, the report would be for account in which case, we allow sending without specifying a reason */}
+      {issue || !atUri ? (
         <>
           <SendReportButton
             onPress={submitReport}
diff --git a/src/view/com/modals/report/ReasonOptions.tsx b/src/view/com/modals/report/ReasonOptions.tsx
new file mode 100644
index 000000000..23b49b664
--- /dev/null
+++ b/src/view/com/modals/report/ReasonOptions.tsx
@@ -0,0 +1,123 @@
+import {View} from 'react-native'
+import React, {useMemo} from 'react'
+import {AtUri, ComAtprotoModerationDefs} from '@atproto/api'
+
+import {Text} from '../../util/text/Text'
+import {UsePaletteValue, usePalette} from 'lib/hooks/usePalette'
+import {RadioGroup, RadioGroupItem} from 'view/com/util/forms/RadioGroup'
+import {CollectionId} from './types'
+
+type ReasonMap = Record<string, {title: string; description: string}>
+const CommonReasons = {
+  [ComAtprotoModerationDefs.REASONRUDE]: {
+    title: 'Anti-Social Behavior',
+    description: 'Harassment, trolling, or intolerance',
+  },
+  [ComAtprotoModerationDefs.REASONVIOLATION]: {
+    title: 'Illegal and Urgent',
+    description: 'Glaring violations of law or terms of service',
+  },
+  [ComAtprotoModerationDefs.REASONOTHER]: {
+    title: 'Other',
+    description: 'An issue not included in these options',
+  },
+}
+const CollectionToReasonsMap: Record<string, ReasonMap> = {
+  [CollectionId.Post]: {
+    [ComAtprotoModerationDefs.REASONSPAM]: {
+      title: 'Spam',
+      description: 'Excessive mentions or replies',
+    },
+    [ComAtprotoModerationDefs.REASONSEXUAL]: {
+      title: 'Unwanted Sexual Content',
+      description: 'Nudity or pornography not labeled as such',
+    },
+    __copyright__: {
+      title: 'Copyright Violation',
+      description: 'Contains copyrighted material',
+    },
+    ...CommonReasons,
+  },
+  [CollectionId.List]: {
+    ...CommonReasons,
+    [ComAtprotoModerationDefs.REASONVIOLATION]: {
+      title: 'Name or Description Violates Community Standards',
+      description: 'Terms used violate community standards',
+    },
+  },
+}
+const AccountReportReasons = {
+  [ComAtprotoModerationDefs.REASONMISLEADING]: {
+    title: 'Misleading Account',
+    description: 'Impersonation or false claims about identity or affiliation',
+  },
+  [ComAtprotoModerationDefs.REASONSPAM]: {
+    title: 'Frequently Posts Unwanted Content',
+    description: 'Spam; excessive mentions or replies',
+  },
+  [ComAtprotoModerationDefs.REASONVIOLATION]: {
+    title: 'Name or Description Violates Community Standards',
+    description: 'Terms used violate community standards',
+  },
+}
+
+const Option = ({
+  pal,
+  title,
+  description,
+}: {
+  pal: UsePaletteValue
+  description: string
+  title: string
+}) => {
+  return (
+    <View>
+      <Text style={pal.text} type="md-bold">
+        {title}
+      </Text>
+      <Text style={pal.textLight}>{description}</Text>
+    </View>
+  )
+}
+
+// This is mostly just content copy without almost any logic
+// so this may grow over time and it makes sense to split it up into its own file
+// to keep it separate from the actual reporting modal logic
+const useReportRadioOptions = (pal: UsePaletteValue, atUri: AtUri | null) =>
+  useMemo(() => {
+    let items: ReasonMap = {...CommonReasons}
+    // If no atUri is passed, that means the reporting collection is account
+    if (!atUri) {
+      items = {...AccountReportReasons}
+    }
+
+    if (atUri?.collection && CollectionToReasonsMap[atUri.collection]) {
+      items = {...CollectionToReasonsMap[atUri.collection]}
+    }
+
+    return Object.entries(items).map(([key, {title, description}]) => ({
+      key,
+      label: <Option pal={pal} title={title} description={description} />,
+    }))
+  }, [pal, atUri])
+
+export const ReportReasonOptions = ({
+  atUri,
+  selectedIssue,
+  onSelectIssue,
+}: {
+  atUri: AtUri | null
+  selectedIssue?: string
+  onSelectIssue: (key: string) => void
+}) => {
+  const pal = usePalette('default')
+  const ITEMS: RadioGroupItem[] = useReportRadioOptions(pal, atUri)
+  return (
+    <RadioGroup
+      items={ITEMS}
+      onSelect={onSelectIssue}
+      testID="reportReasonRadios"
+      initialSelection={selectedIssue}
+    />
+  )
+}
diff --git a/src/view/com/modals/report/ReportAccount.tsx b/src/view/com/modals/report/ReportAccount.tsx
deleted file mode 100644
index b53c54caa..000000000
--- a/src/view/com/modals/report/ReportAccount.tsx
+++ /dev/null
@@ -1,197 +0,0 @@
-import React, {useState, useMemo} from 'react'
-import {TouchableOpacity, StyleSheet, View} from 'react-native'
-import {ScrollView} from 'react-native-gesture-handler'
-import {ComAtprotoModerationDefs} from '@atproto/api'
-import {useStores} from 'state/index'
-import {s} from 'lib/styles'
-import {RadioGroup, RadioGroupItem} from '../../util/forms/RadioGroup'
-import {Text} from '../../util/text/Text'
-import * as Toast from '../../util/Toast'
-import {ErrorMessage} from '../../util/error/ErrorMessage'
-import {cleanError} from 'lib/strings/errors'
-import {usePalette} from 'lib/hooks/usePalette'
-import {isDesktopWeb} from 'platform/detection'
-import {SendReportButton} from './SendReportButton'
-import {InputIssueDetails} from './InputIssueDetails'
-
-export const snapPoints = [500]
-
-export function Component({did}: {did: string}) {
-  const store = useStores()
-  const pal = usePalette('default')
-  const [isProcessing, setIsProcessing] = useState(false)
-  const [error, setError] = useState<string>()
-  const [issue, setIssue] = useState<string>()
-  const onSelectIssue = (v: string) => setIssue(v)
-  const [details, setDetails] = useState<string>()
-  const [showDetailsInput, setShowDetailsInput] = useState(false)
-
-  const onPress = async () => {
-    setError('')
-    if (!issue) {
-      return
-    }
-    setIsProcessing(true)
-    try {
-      await store.agent.com.atproto.moderation.createReport({
-        reasonType: issue,
-        subject: {
-          $type: 'com.atproto.admin.defs#repoRef',
-          did,
-        },
-        reason: details,
-      })
-      Toast.show("Thank you for your report! We'll look into it promptly.")
-      store.shell.closeModal()
-      return
-    } catch (e: any) {
-      setError(cleanError(e))
-      setIsProcessing(false)
-    }
-  }
-  const goBack = () => {
-    setShowDetailsInput(false)
-  }
-  const goToDetails = () => {
-    setShowDetailsInput(true)
-  }
-
-  return (
-    <ScrollView
-      testID="reportAccountModal"
-      style={[styles.container, pal.view]}>
-      {showDetailsInput ? (
-        <InputIssueDetails
-          submitReport={onPress}
-          setDetails={setDetails}
-          details={details}
-          isProcessing={isProcessing}
-          goBack={goBack}
-        />
-      ) : (
-        <SelectIssue
-          onPress={onPress}
-          onSelectIssue={onSelectIssue}
-          error={error}
-          isProcessing={isProcessing}
-          goToDetails={goToDetails}
-        />
-      )}
-    </ScrollView>
-  )
-}
-
-const SelectIssue = ({
-  onPress,
-  onSelectIssue,
-  error,
-  isProcessing,
-  goToDetails,
-}: {
-  onPress: () => void
-  onSelectIssue: (v: string) => void
-  error: string | undefined
-  isProcessing: boolean
-  goToDetails: () => void
-}) => {
-  const pal = usePalette('default')
-  const ITEMS: RadioGroupItem[] = useMemo(
-    () => [
-      {
-        key: ComAtprotoModerationDefs.REASONMISLEADING,
-        label: (
-          <View>
-            <Text style={pal.text} type="md-bold">
-              Misleading Account
-            </Text>
-            <Text style={pal.textLight}>
-              Impersonation or false claims about identity or affiliation
-            </Text>
-          </View>
-        ),
-      },
-      {
-        key: ComAtprotoModerationDefs.REASONSPAM,
-        label: (
-          <View>
-            <Text style={pal.text} type="md-bold">
-              Frequently Posts Unwanted Content
-            </Text>
-            <Text style={pal.textLight}>
-              Spam; excessive mentions or replies
-            </Text>
-          </View>
-        ),
-      },
-      {
-        key: ComAtprotoModerationDefs.REASONVIOLATION,
-        label: (
-          <View>
-            <Text style={pal.text} type="md-bold">
-              Name or Description Violates Community Standards
-            </Text>
-            <Text style={pal.textLight}>
-              Terms used violate community standards
-            </Text>
-          </View>
-        ),
-      },
-    ],
-    [pal],
-  )
-  return (
-    <>
-      <Text type="title-xl" style={[pal.text, styles.title]}>
-        Report Account
-      </Text>
-      <Text type="xl" style={[pal.text, styles.description]}>
-        What is the issue with this account?
-      </Text>
-      <RadioGroup
-        testID="reportAccountRadios"
-        items={ITEMS}
-        onSelect={onSelectIssue}
-      />
-      <Text type="sm" style={[pal.text, styles.description, s.pt10]}>
-        For other issues, please report specific posts.
-      </Text>
-      {error ? (
-        <View style={s.mt10}>
-          <ErrorMessage message={error} />
-        </View>
-      ) : undefined}
-      <SendReportButton onPress={onPress} isProcessing={isProcessing} />
-      <TouchableOpacity
-        testID="addDetailsBtn"
-        style={styles.addDetailsBtn}
-        onPress={goToDetails}
-        accessibilityRole="button"
-        accessibilityLabel="Add details"
-        accessibilityHint="Add more details to your report">
-        <Text style={[s.f18, pal.link]}>Add details to report</Text>
-      </TouchableOpacity>
-    </>
-  )
-}
-
-const styles = StyleSheet.create({
-  container: {
-    flex: 1,
-    paddingHorizontal: isDesktopWeb ? 0 : 10,
-  },
-  title: {
-    textAlign: 'center',
-    fontWeight: 'bold',
-    marginBottom: 12,
-  },
-  description: {
-    textAlign: 'center',
-    paddingHorizontal: 22,
-    marginBottom: 10,
-  },
-  addDetailsBtn: {
-    padding: 14,
-    alignSelf: 'center',
-    marginBottom: 40,
-  },
-})
diff --git a/src/view/com/modals/report/types.ts b/src/view/com/modals/report/types.ts
new file mode 100644
index 000000000..ca947ecbd
--- /dev/null
+++ b/src/view/com/modals/report/types.ts
@@ -0,0 +1,8 @@
+// TODO: ATM, @atproto/api does not export ids but it does have these listed at @atproto/api/client/lexicons
+// once we start exporting the ids from the @atproto/ap package, replace these hardcoded ones
+export enum CollectionId {
+  FeedGenerator = 'app.bsky.feed.generator',
+  Profile = 'app.bsky.actor.profile',
+  List = 'app.bsky.graph.list',
+  Post = 'app.bsky.feed.post',
+}