about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/lib/api/index.ts12
-rw-r--r--src/lib/icons.tsx38
-rw-r--r--src/lib/moderation.ts12
-rw-r--r--src/state/models/ui/preferences.ts47
-rw-r--r--src/state/models/ui/shell.ts7
-rw-r--r--src/view/com/composer/Composer.tsx117
-rw-r--r--src/view/com/composer/labels/LabelsBtn.tsx53
-rw-r--r--src/view/com/modals/Modal.tsx4
-rw-r--r--src/view/com/modals/Modal.web.tsx3
-rw-r--r--src/view/com/modals/ModerationDetails.tsx8
-rw-r--r--src/view/com/modals/SelfLabel.tsx167
-rw-r--r--src/view/com/posts/FeedItem.tsx2
-rw-r--r--src/view/com/util/forms/SelectableBtn.tsx23
-rw-r--r--src/view/com/util/moderation/ContentHider.tsx4
-rw-r--r--src/view/com/util/moderation/PostAlerts.tsx4
-rw-r--r--src/view/com/util/moderation/PostHider.tsx4
-rw-r--r--src/view/com/util/moderation/ProfileHeaderAlerts.tsx4
-rw-r--r--src/view/index.ts4
18 files changed, 399 insertions, 114 deletions
diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts
index 381d78435..bb4ff8fcb 100644
--- a/src/lib/api/index.ts
+++ b/src/lib/api/index.ts
@@ -4,6 +4,7 @@ import {
   AppBskyEmbedRecord,
   AppBskyEmbedRecordWithMedia,
   AppBskyRichtextFacet,
+  ComAtprotoLabelDefs,
   ComAtprotoRepoUploadBlob,
   RichText,
 } from '@atproto/api'
@@ -77,6 +78,7 @@ interface PostOpts {
   }
   extLink?: ExternalEmbedDraft
   images?: ImageModel[]
+  labels?: string[]
   knownHandles?: Set<string>
   onStateChange?: (state: string) => void
   langs?: string[]
@@ -234,6 +236,15 @@ export async function post(store: RootStoreModel, opts: PostOpts) {
     }
   }
 
+  // set labels
+  let labels: ComAtprotoLabelDefs.SelfLabels | undefined
+  if (opts.labels?.length) {
+    labels = {
+      $type: 'com.atproto.label.defs#selfLabels',
+      values: opts.labels.map(val => ({val})),
+    }
+  }
+
   // add top 3 languages from user preferences if langs is provided
   let langs = opts.langs
   if (opts.langs) {
@@ -248,6 +259,7 @@ export async function post(store: RootStoreModel, opts: PostOpts) {
       reply,
       embed,
       langs,
+      labels,
     })
   } catch (e: any) {
     console.error(`Failed to create post: ${e.toString()}`)
diff --git a/src/lib/icons.tsx b/src/lib/icons.tsx
index 4ea3b4d65..233f8a473 100644
--- a/src/lib/icons.tsx
+++ b/src/lib/icons.tsx
@@ -957,3 +957,41 @@ export function SatelliteDishIcon({
     </Svg>
   )
 }
+
+// Copyright (c) 2020 Refactoring UI Inc.
+// https://github.com/tailwindlabs/heroicons/blob/master/LICENSE
+export function ShieldExclamation({
+  style,
+  size,
+  strokeWidth = 1.5,
+}: {
+  style?: StyleProp<TextStyle>
+  size?: string | number
+  strokeWidth?: number
+}) {
+  let color = 'currentColor'
+  if (
+    style &&
+    typeof style === 'object' &&
+    'color' in style &&
+    typeof style.color === 'string'
+  ) {
+    color = style.color
+  }
+  return (
+    <Svg
+      width={size}
+      height={size}
+      fill="none"
+      viewBox="0 0 24 24"
+      strokeWidth={strokeWidth || 1.5}
+      stroke={color}
+      style={style}>
+      <Path
+        strokeLinecap="round"
+        strokeLinejoin="round"
+        d="M12 9v3.75m0-10.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.75c0 5.592 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.57-.598-3.75h-.152c-3.196 0-6.1-1.249-8.25-3.286zm0 13.036h.008v.008H12v-.008z"
+      />
+    </Svg>
+  )
+}
diff --git a/src/lib/moderation.ts b/src/lib/moderation.ts
index 1ed830fd8..70c4444fe 100644
--- a/src/lib/moderation.ts
+++ b/src/lib/moderation.ts
@@ -29,12 +29,7 @@ export function describeModerationCause(
     }
   }
   if (cause.type === 'muted') {
-    if (cause.source.type === 'user') {
-      return {
-        name: context === 'account' ? 'Muted User' : 'Post by muted user',
-        description: 'You have muted this user',
-      }
-    } else {
+    if (cause.source.type === 'list') {
       return {
         name:
           context === 'account'
@@ -42,6 +37,11 @@ export function describeModerationCause(
             : `Post by muted user ("${cause.source.list.name}")`,
         description: 'You have muted this user',
       }
+    } else {
+      return {
+        name: context === 'account' ? 'Muted User' : 'Post by muted user',
+        description: 'You have muted this user',
+      }
     }
   }
   return cause.labelDef.strings[context].en
diff --git a/src/state/models/ui/preferences.ts b/src/state/models/ui/preferences.ts
index a892d8d34..23668a3dc 100644
--- a/src/state/models/ui/preferences.ts
+++ b/src/state/models/ui/preferences.ts
@@ -418,34 +418,35 @@ export class PreferencesModel {
     return {
       userDid: this.rootStore.session.currentSession?.did || '',
       adultContentEnabled: this.adultContentEnabled,
-      labelerSettings: [
+      labels: {
+        // TEMP translate old settings until this UI can be migrated -prf
+        porn: tempfixLabelPref(this.contentLabels.nsfw),
+        sexual: tempfixLabelPref(this.contentLabels.suggestive),
+        nudity: tempfixLabelPref(this.contentLabels.nudity),
+        nsfl: tempfixLabelPref(this.contentLabels.gore),
+        corpse: tempfixLabelPref(this.contentLabels.gore),
+        gore: tempfixLabelPref(this.contentLabels.gore),
+        torture: tempfixLabelPref(this.contentLabels.gore),
+        'self-harm': tempfixLabelPref(this.contentLabels.gore),
+        'intolerant-race': tempfixLabelPref(this.contentLabels.hate),
+        'intolerant-gender': tempfixLabelPref(this.contentLabels.hate),
+        'intolerant-sexual-orientation': tempfixLabelPref(
+          this.contentLabels.hate,
+        ),
+        'intolerant-religion': tempfixLabelPref(this.contentLabels.hate),
+        intolerant: tempfixLabelPref(this.contentLabels.hate),
+        'icon-intolerant': tempfixLabelPref(this.contentLabels.hate),
+        spam: tempfixLabelPref(this.contentLabels.spam),
+        impersonation: tempfixLabelPref(this.contentLabels.impersonation),
+        scam: 'warn',
+      },
+      labelers: [
         {
           labeler: {
             did: '',
             displayName: 'Bluesky Social',
           },
-          settings: {
-            // TEMP translate old settings until this UI can be migrated -prf
-            porn: tempfixLabelPref(this.contentLabels.nsfw),
-            sexual: tempfixLabelPref(this.contentLabels.suggestive),
-            nudity: tempfixLabelPref(this.contentLabels.nudity),
-            nsfl: tempfixLabelPref(this.contentLabels.gore),
-            corpse: tempfixLabelPref(this.contentLabels.gore),
-            gore: tempfixLabelPref(this.contentLabels.gore),
-            torture: tempfixLabelPref(this.contentLabels.gore),
-            'self-harm': tempfixLabelPref(this.contentLabels.gore),
-            'intolerant-race': tempfixLabelPref(this.contentLabels.hate),
-            'intolerant-gender': tempfixLabelPref(this.contentLabels.hate),
-            'intolerant-sexual-orientation': tempfixLabelPref(
-              this.contentLabels.hate,
-            ),
-            'intolerant-religion': tempfixLabelPref(this.contentLabels.hate),
-            intolerant: tempfixLabelPref(this.contentLabels.hate),
-            'icon-intolerant': tempfixLabelPref(this.contentLabels.hate),
-            spam: tempfixLabelPref(this.contentLabels.spam),
-            impersonation: tempfixLabelPref(this.contentLabels.impersonation),
-            scam: 'warn',
-          },
+          labels: {},
         },
       ],
     }
diff --git a/src/state/models/ui/shell.ts b/src/state/models/ui/shell.ts
index 476277592..348fa4899 100644
--- a/src/state/models/ui/shell.ts
+++ b/src/state/models/ui/shell.ts
@@ -100,6 +100,12 @@ export interface RepostModal {
   isReposted: boolean
 }
 
+export interface SelfLabelModal {
+  name: 'self-label'
+  labels: string[]
+  onChange: (labels: string[]) => void
+}
+
 export interface ChangeHandleModal {
   name: 'change-handle'
   onChanged: () => void
@@ -164,6 +170,7 @@ export type Modal =
   | EditImageModal
   | ServerInputModal
   | RepostModal
+  | SelfLabelModal
 
   // Bluesky access
   | WaitlistModal
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx
index 9fdf0bc6f..7d3e27571 100644
--- a/src/view/com/composer/Composer.tsx
+++ b/src/view/com/composer/Composer.tsx
@@ -41,6 +41,7 @@ import {isDesktopWeb, isAndroid, isIOS} from 'platform/detection'
 import {GalleryModel} from 'state/models/media/gallery'
 import {Gallery} from './photos/Gallery'
 import {MAX_GRAPHEME_LENGTH} from 'lib/constants'
+import {LabelsBtn} from './labels/LabelsBtn'
 import {SelectLangBtn} from './select-language/SelectLangBtn'
 
 type Props = ComposerOpts & {
@@ -67,6 +68,7 @@ export const ComposePost = observer(function ComposePost({
     initQuote,
   )
   const {extLink, setExtLink} = useExternalLinkFetch({setQuote})
+  const [labels, setLabels] = useState<string[]>([])
   const [suggestedLinks, setSuggestedLinks] = useState<Set<string>>(new Set())
   const gallery = useMemo(() => new GalleryModel(store), [store])
 
@@ -145,75 +147,59 @@ export const ComposePost = observer(function ComposePost({
     [gallery, track],
   )
 
-  const onPressPublish = useCallback(
-    async (rt: RichText) => {
-      if (isProcessing || rt.graphemeLength > MAX_GRAPHEME_LENGTH) {
-        return
-      }
-      if (store.preferences.requireAltTextEnabled && gallery.needsAltText) {
-        return
-      }
+  const onPressPublish = async (rt: RichText) => {
+    if (isProcessing || rt.graphemeLength > MAX_GRAPHEME_LENGTH) {
+      return
+    }
+    if (store.preferences.requireAltTextEnabled && gallery.needsAltText) {
+      return
+    }
 
-      setError('')
+    setError('')
 
-      if (rt.text.trim().length === 0 && gallery.isEmpty) {
-        setError('Did you want to say anything?')
-        return
-      }
+    if (rt.text.trim().length === 0 && gallery.isEmpty) {
+      setError('Did you want to say anything?')
+      return
+    }
 
-      setIsProcessing(true)
+    setIsProcessing(true)
 
-      try {
-        await apilib.post(store, {
-          rawText: rt.text,
-          replyTo: replyTo?.uri,
-          images: gallery.images,
-          quote: quote,
-          extLink: extLink,
-          onStateChange: setProcessingState,
-          knownHandles: autocompleteView.knownHandles,
-          langs: store.preferences.postLanguages,
-        })
-      } catch (e: any) {
-        if (extLink) {
-          setExtLink({
-            ...extLink,
-            isLoading: true,
-            localThumb: undefined,
-          } as apilib.ExternalEmbedDraft)
-        }
-        setError(cleanError(e.message))
-        setIsProcessing(false)
-        return
-      } finally {
-        track('Create Post', {
-          imageCount: gallery.size,
-        })
-        if (replyTo && replyTo.uri) track('Post:Reply')
-      }
-      if (!replyTo) {
-        store.me.mainFeed.onPostCreated()
+    try {
+      await apilib.post(store, {
+        rawText: rt.text,
+        replyTo: replyTo?.uri,
+        images: gallery.images,
+        quote,
+        extLink,
+        labels,
+        onStateChange: setProcessingState,
+        knownHandles: autocompleteView.knownHandles,
+        langs: store.preferences.postLanguages,
+      })
+    } catch (e: any) {
+      if (extLink) {
+        setExtLink({
+          ...extLink,
+          isLoading: true,
+          localThumb: undefined,
+        } as apilib.ExternalEmbedDraft)
       }
-      onPost?.()
-      onClose()
-      Toast.show(`Your ${replyTo ? 'reply' : 'post'} has been published`)
-    },
-    [
-      isProcessing,
-      setError,
-      setIsProcessing,
-      replyTo,
-      autocompleteView.knownHandles,
-      extLink,
-      onClose,
-      onPost,
-      quote,
-      setExtLink,
-      store,
-      track,
-      gallery,
-    ],
-  )
+      setError(cleanError(e.message))
+      setIsProcessing(false)
+      return
+    } finally {
+      track('Create Post', {
+        imageCount: gallery.size,
+      })
+      if (replyTo && replyTo.uri) track('Post:Reply')
+    }
+    if (!replyTo) {
+      store.me.mainFeed.onPostCreated()
+    }
+    onPost?.()
+    onClose()
+    Toast.show(`Your ${replyTo ? 'reply' : 'post'} has been published`)
+  }
 
   const canPost = useMemo(
     () =>
@@ -246,6 +232,7 @@ export const ComposePost = observer(function ComposePost({
             <Text style={[pal.link, s.f18]}>Cancel</Text>
           </TouchableOpacity>
           <View style={s.flex1} />
+          <LabelsBtn labels={labels} onChange={setLabels} />
           {isProcessing ? (
             <View style={styles.postBtn}>
               <ActivityIndicator />
@@ -407,7 +394,7 @@ const styles = StyleSheet.create({
     flexDirection: 'row',
     alignItems: 'center',
     paddingTop: isDesktopWeb ? 10 : undefined,
-    paddingBottom: 10,
+    paddingBottom: isDesktopWeb ? 10 : 4,
     paddingHorizontal: 20,
     height: 55,
   },
diff --git a/src/view/com/composer/labels/LabelsBtn.tsx b/src/view/com/composer/labels/LabelsBtn.tsx
new file mode 100644
index 000000000..f41dd2600
--- /dev/null
+++ b/src/view/com/composer/labels/LabelsBtn.tsx
@@ -0,0 +1,53 @@
+import React from 'react'
+import {StyleSheet} from 'react-native'
+import {observer} from 'mobx-react-lite'
+import {Button} from 'view/com/util/forms/Button'
+import {usePalette} from 'lib/hooks/usePalette'
+import {useStores} from 'state/index'
+import {ShieldExclamation} from 'lib/icons'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome'
+
+export const LabelsBtn = observer(function LabelsBtn({
+  labels,
+  onChange,
+}: {
+  labels: string[]
+  onChange: (v: string[]) => void
+}) {
+  const pal = usePalette('default')
+  const store = useStores()
+
+  return (
+    <Button
+      type="default-light"
+      testID="labelsBtn"
+      style={styles.button}
+      accessibilityLabel="Content warnings"
+      accessibilityHint=""
+      onPress={() =>
+        store.shell.openModal({name: 'self-label', labels, onChange})
+      }>
+      <ShieldExclamation style={pal.link} size={26} />
+      {labels.length > 0 ? (
+        <FontAwesomeIcon
+          icon="check"
+          size={16}
+          style={pal.link as FontAwesomeIconStyle}
+        />
+      ) : null}
+    </Button>
+  )
+})
+
+const styles = StyleSheet.create({
+  button: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    paddingHorizontal: 14,
+    marginRight: 4,
+  },
+  label: {
+    maxWidth: 100,
+  },
+})
diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx
index 469492971..ce5dc40e0 100644
--- a/src/view/com/modals/Modal.tsx
+++ b/src/view/com/modals/Modal.tsx
@@ -15,6 +15,7 @@ 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'
@@ -104,6 +105,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} />
diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx
index df13dfed2..0f6247822 100644
--- a/src/view/com/modals/Modal.web.tsx
+++ b/src/view/com/modals/Modal.web.tsx
@@ -16,6 +16,7 @@ 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'
@@ -89,6 +90,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') {
diff --git a/src/view/com/modals/ModerationDetails.tsx b/src/view/com/modals/ModerationDetails.tsx
index abeb2fdf4..598d26924 100644
--- a/src/view/com/modals/ModerationDetails.tsx
+++ b/src/view/com/modals/ModerationDetails.tsx
@@ -35,10 +35,7 @@ export function Component({
     name = 'Account Blocks You'
     description = 'This user has blocked you. You cannot view their content.'
   } else if (moderation.cause.type === 'muted') {
-    if (moderation.cause.source.type === 'user') {
-      name = 'Account Muted'
-      description = 'You have muted this user.'
-    } else {
+    if (moderation.cause.source.type === 'list') {
       const list = moderation.cause.source.list
       name = <>Account Muted by List</>
       description = (
@@ -53,6 +50,9 @@ export function Component({
           list which you have muted.
         </>
       )
+    } else {
+      name = 'Account Muted'
+      description = 'You have muted this user.'
     }
   } else {
     name = moderation.cause.labelDef.strings[context].en.name
diff --git a/src/view/com/modals/SelfLabel.tsx b/src/view/com/modals/SelfLabel.tsx
new file mode 100644
index 000000000..cb78f3f15
--- /dev/null
+++ b/src/view/com/modals/SelfLabel.tsx
@@ -0,0 +1,167 @@
+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 {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,
+  onChange,
+}: {
+  labels: string[]
+  onChange: (labels: string[]) => void
+}) {
+  const pal = usePalette('default')
+  const store = useStores()
+  const [selected, setSelected] = useState(labels)
+
+  const toggleAdultContent = (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)
+  }
+
+  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>
+
+            <Text type="lg" style={pal.text}>
+              {selected.includes('sexual') ? (
+                <>😏</>
+              ) : selected.includes('nudity') ? (
+                <>🫣</>
+              ) : selected.includes('porn') ? (
+                <>🥵</>
+              ) : (
+                <></>
+              )}
+            </Text>
+          </View>
+          <View style={s.flexRow}>
+            <SelectableBtn
+              testID="sexualLabelBtn"
+              selected={selected.includes('sexual')}
+              left
+              label="Suggestive"
+              onSelect={() => toggleAdultContent('sexual')}
+              accessibilityHint=""
+              style={s.flex1}
+            />
+            <SelectableBtn
+              testID="nudityLabelBtn"
+              selected={selected.includes('nudity')}
+              label="Nudity"
+              onSelect={() => toggleAdultContent('nudity')}
+              accessibilityHint=""
+              style={s.flex1}
+            />
+            <SelectableBtn
+              testID="pornLabelBtn"
+              selected={selected.includes('porn')}
+              label="Porn"
+              right
+              onSelect={() => toggleAdultContent('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>
+      </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/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx
index bed3d3a61..60dff9153 100644
--- a/src/view/com/posts/FeedItem.tsx
+++ b/src/view/com/posts/FeedItem.tsx
@@ -271,6 +271,7 @@ export const FeedItem = observer(function ({
             </View>
           )}
           <ContentHider
+            testID="contentHider-post"
             moderation={item.moderation.content}
             ignoreMute
             style={styles.contentHider}
@@ -291,6 +292,7 @@ export const FeedItem = observer(function ({
             ) : undefined}
             {item.post.embed ? (
               <ContentHider
+                testID="contentHider-embed"
                 moderation={item.moderation.embed}
                 style={styles.embed}>
                 <PostEmbeds
diff --git a/src/view/com/util/forms/SelectableBtn.tsx b/src/view/com/util/forms/SelectableBtn.tsx
index 503c49b2f..4b494264e 100644
--- a/src/view/com/util/forms/SelectableBtn.tsx
+++ b/src/view/com/util/forms/SelectableBtn.tsx
@@ -5,6 +5,7 @@ import {usePalette} from 'lib/hooks/usePalette'
 import {isDesktopWeb} from 'platform/detection'
 
 interface SelectableBtnProps {
+  testID?: string
   selected: boolean
   label: string
   left?: boolean
@@ -15,6 +16,7 @@ interface SelectableBtnProps {
 }
 
 export function SelectableBtn({
+  testID,
   selected,
   label,
   left,
@@ -25,12 +27,15 @@ export function SelectableBtn({
 }: SelectableBtnProps) {
   const pal = usePalette('default')
   const palPrimary = usePalette('inverted')
+  const needsWidthStyles = !style || !('width' in style || 'flex' in style)
   return (
     <Pressable
+      testID={testID}
       style={[
-        styles.selectableBtn,
-        left && styles.selectableBtnLeft,
-        right && styles.selectableBtnRight,
+        styles.btn,
+        needsWidthStyles && styles.btnWidth,
+        left && styles.btnLeft,
+        right && styles.btnRight,
         pal.border,
         selected ? palPrimary.view : pal.view,
         style,
@@ -45,9 +50,7 @@ export function SelectableBtn({
 }
 
 const styles = StyleSheet.create({
-  selectableBtn: {
-    flex: isDesktopWeb ? undefined : 1,
-    width: isDesktopWeb ? 100 : undefined,
+  btn: {
     flexDirection: 'row',
     justifyContent: 'center',
     borderWidth: 1,
@@ -55,12 +58,16 @@ const styles = StyleSheet.create({
     paddingHorizontal: 10,
     paddingVertical: 10,
   },
-  selectableBtnLeft: {
+  btnWidth: {
+    flex: isDesktopWeb ? undefined : 1,
+    width: isDesktopWeb ? 100 : undefined,
+  },
+  btnLeft: {
     borderTopLeftRadius: 8,
     borderBottomLeftRadius: 8,
     borderLeftWidth: 1,
   },
-  selectableBtnRight: {
+  btnRight: {
     borderTopRightRadius: 8,
     borderBottomRightRadius: 8,
   },
diff --git a/src/view/com/util/moderation/ContentHider.tsx b/src/view/com/util/moderation/ContentHider.tsx
index 6be2f8be0..9286d1c8b 100644
--- a/src/view/com/util/moderation/ContentHider.tsx
+++ b/src/view/com/util/moderation/ContentHider.tsx
@@ -3,7 +3,7 @@ import {Pressable, StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
 import {usePalette} from 'lib/hooks/usePalette'
 import {ModerationUI} from '@atproto/api'
 import {Text} from '../text/Text'
-import {InfoCircleIcon} from 'lib/icons'
+import {ShieldExclamation} from 'lib/icons'
 import {describeModerationCause} from 'lib/moderation'
 import {useStores} from 'state/index'
 import {isDesktopWeb} from 'platform/detection'
@@ -58,7 +58,7 @@ export function ContentHider({
           accessibilityRole="button"
           accessibilityLabel="Learn more about this warning"
           accessibilityHint="">
-          <InfoCircleIcon size={18} style={pal.text} />
+          <ShieldExclamation size={18} style={pal.text} />
         </Pressable>
         <Text type="lg" style={pal.text}>
           {desc.name}
diff --git a/src/view/com/util/moderation/PostAlerts.tsx b/src/view/com/util/moderation/PostAlerts.tsx
index 45937c2d8..8a6cbbb85 100644
--- a/src/view/com/util/moderation/PostAlerts.tsx
+++ b/src/view/com/util/moderation/PostAlerts.tsx
@@ -3,7 +3,7 @@ import {Pressable, StyleProp, StyleSheet, ViewStyle} from 'react-native'
 import {ModerationUI} from '@atproto/api'
 import {Text} from '../text/Text'
 import {usePalette} from 'lib/hooks/usePalette'
-import {InfoCircleIcon} from 'lib/icons'
+import {ShieldExclamation} from 'lib/icons'
 import {describeModerationCause} from 'lib/moderation'
 import {useStores} from 'state/index'
 
@@ -41,7 +41,7 @@ export function PostAlerts({
       accessibilityLabel="Learn more about this warning"
       accessibilityHint=""
       style={[styles.container, pal.viewLight, style]}>
-      <InfoCircleIcon style={pal.text} size={18} />
+      <ShieldExclamation style={pal.text} size={16} />
       <Text type="lg" style={pal.text}>
         {desc.name}
       </Text>
diff --git a/src/view/com/util/moderation/PostHider.tsx b/src/view/com/util/moderation/PostHider.tsx
index dc74d3e39..2a52561d4 100644
--- a/src/view/com/util/moderation/PostHider.tsx
+++ b/src/view/com/util/moderation/PostHider.tsx
@@ -6,7 +6,7 @@ import {Link} from '../Link'
 import {Text} from '../text/Text'
 import {addStyle} from 'lib/styles'
 import {describeModerationCause} from 'lib/moderation'
-import {InfoCircleIcon} from 'lib/icons'
+import {ShieldExclamation} from 'lib/icons'
 import {useStores} from 'state/index'
 import {isDesktopWeb} from 'platform/detection'
 
@@ -67,7 +67,7 @@ export function PostHider({
           accessibilityRole="button"
           accessibilityLabel="Learn more about this warning"
           accessibilityHint="">
-          <InfoCircleIcon size={18} style={pal.text} />
+          <ShieldExclamation size={18} style={pal.text} />
         </Pressable>
         <Text type="lg" style={pal.text}>
           {desc.name}
diff --git a/src/view/com/util/moderation/ProfileHeaderAlerts.tsx b/src/view/com/util/moderation/ProfileHeaderAlerts.tsx
index 3cc3b5b9e..b7781e06d 100644
--- a/src/view/com/util/moderation/ProfileHeaderAlerts.tsx
+++ b/src/view/com/util/moderation/ProfileHeaderAlerts.tsx
@@ -3,7 +3,7 @@ import {Pressable, StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
 import {ProfileModeration} from '@atproto/api'
 import {Text} from '../text/Text'
 import {usePalette} from 'lib/hooks/usePalette'
-import {InfoCircleIcon} from 'lib/icons'
+import {ShieldExclamation} from 'lib/icons'
 import {
   describeModerationCause,
   getProfileModerationCauses,
@@ -44,7 +44,7 @@ export function ProfileHeaderAlerts({
             accessibilityLabel="Learn more about this warning"
             accessibilityHint=""
             style={[styles.container, pal.viewLight, style]}>
-            <InfoCircleIcon style={pal.text} size={24} />
+            <ShieldExclamation style={pal.text} size={24} />
             <Text type="lg" style={pal.text}>
               {desc.name}
             </Text>
diff --git a/src/view/index.ts b/src/view/index.ts
index 4226e07e7..22cc6e837 100644
--- a/src/view/index.ts
+++ b/src/view/index.ts
@@ -72,6 +72,8 @@ import {faShareFromSquare} from '@fortawesome/free-solid-svg-icons/faShareFromSq
 import {faShield} from '@fortawesome/free-solid-svg-icons/faShield'
 import {faSignal} from '@fortawesome/free-solid-svg-icons/faSignal'
 import {faSliders} from '@fortawesome/free-solid-svg-icons/faSliders'
+import {faSquare} from '@fortawesome/free-regular-svg-icons/faSquare'
+import {faSquareCheck} from '@fortawesome/free-regular-svg-icons/faSquareCheck'
 import {faSquarePlus} from '@fortawesome/free-regular-svg-icons/faSquarePlus'
 import {faTicket} from '@fortawesome/free-solid-svg-icons/faTicket'
 import {faTrashCan} from '@fortawesome/free-regular-svg-icons/faTrashCan'
@@ -162,6 +164,8 @@ export function setup() {
     faShield,
     faSignal,
     faSliders,
+    faSquare,
+    faSquareCheck,
     faSquarePlus,
     faUser,
     faUsers,