about summary refs log tree commit diff
diff options
context:
space:
mode:
authorHailey <me@haileyok.com>2024-10-16 19:17:22 -0700
committerGitHub <noreply@github.com>2024-10-16 19:17:22 -0700
commit2e74f9839246c7dee718ac8bc12c22395ff002b5 (patch)
treec308f1d5e0fe13f2b43b3df08958465507153e15
parent3d9663db1e3a32ac5daeba5cddbcb86b8eaab971 (diff)
downloadvoidsky-2e74f9839246c7dee718ac8bc12c22395ff002b5.tar.zst
Add graphic media self label (#5758)
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
-rw-r--r--src/components/moderation/ContentHider.tsx68
-rw-r--r--src/lib/moderation.ts8
-rw-r--r--src/view/com/composer/labels/LabelsBtn.tsx288
-rw-r--r--src/view/com/composer/state/composer.ts5
4 files changed, 247 insertions, 122 deletions
diff --git a/src/components/moderation/ContentHider.tsx b/src/components/moderation/ContentHider.tsx
index bf9bae517..67aef67b4 100644
--- a/src/components/moderation/ContentHider.tsx
+++ b/src/components/moderation/ContentHider.tsx
@@ -4,9 +4,12 @@ import {ModerationUI} from '@atproto/api'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
-import {isJustAMute} from '#/lib/moderation'
+import {ADULT_CONTENT_LABELS, isJustAMute} from '#/lib/moderation'
+import {useGlobalLabelStrings} from '#/lib/moderation/useGlobalLabelStrings'
+import {getDefinition, getLabelStrings} from '#/lib/moderation/useLabelInfo'
 import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription'
 import {sanitizeDisplayName} from '#/lib/strings/display-names'
+import {useLabelDefinitions} from '#/state/preferences'
 import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
 import {Button} from '#/components/Button'
 import {
@@ -34,10 +37,68 @@ export function ContentHider({
   const {gtMobile} = useBreakpoints()
   const [override, setOverride] = React.useState(false)
   const control = useModerationDetailsDialogControl()
+  const {labelDefs} = useLabelDefinitions()
+  const globalLabelStrings = useGlobalLabelStrings()
+  const {i18n} = useLingui()
 
   const blur = modui?.blurs[0]
   const desc = useModerationCauseDescription(blur)
 
+  const labelName = React.useMemo(() => {
+    if (!modui?.blurs || !blur) {
+      return undefined
+    }
+    if (
+      blur.type !== 'label' ||
+      (blur.type === 'label' && blur.source.type !== 'user')
+    ) {
+      return desc.name
+    }
+
+    let hasAdultContentLabel = false
+    const selfBlurNames = modui.blurs
+      .filter(cause => {
+        if (cause.type !== 'label') {
+          return false
+        }
+        if (cause.source.type !== 'user') {
+          return false
+        }
+        if (ADULT_CONTENT_LABELS.includes(cause.label.val)) {
+          if (hasAdultContentLabel) {
+            return false
+          }
+          hasAdultContentLabel = true
+        }
+        return true
+      })
+      .slice(0, 2)
+      .map(cause => {
+        if (cause.type !== 'label') {
+          return
+        }
+
+        const def = cause.labelDef || getDefinition(labelDefs, cause.label)
+        if (def.identifier === 'porn' || def.identifier === 'sexual') {
+          return _(msg`Adult Content`)
+        }
+        return getLabelStrings(i18n.locale, globalLabelStrings, def).name
+      })
+
+    if (selfBlurNames.length === 0) {
+      return desc.name
+    }
+    return [...new Set(selfBlurNames)].join(', ')
+  }, [
+    _,
+    modui?.blurs,
+    blur,
+    desc.name,
+    labelDefs,
+    i18n.locale,
+    globalLabelStrings,
+  ])
+
   if (!blur || (ignoreMute && isJustAMute(modui))) {
     return (
       <View testID={testID} style={style}>
@@ -99,8 +160,9 @@ export function ContentHider({
                 web({
                   marginBottom: 1,
                 }),
-              ]}>
-              {desc.name}
+              ]}
+              numberOfLines={2}>
+              {labelName}
             </Text>
             {!modui.noOverride && (
               <Text
diff --git a/src/lib/moderation.ts b/src/lib/moderation.ts
index 7576a9c33..be503f4c7 100644
--- a/src/lib/moderation.ts
+++ b/src/lib/moderation.ts
@@ -14,6 +14,14 @@ import {sanitizeDisplayName} from '#/lib/strings/display-names'
 import {sanitizeHandle} from '#/lib/strings/handles'
 import {AppModerationCause} from '#/components/Pills'
 
+export const ADULT_CONTENT_LABELS = ['sexual', 'nudity', 'porn']
+export const OTHER_SELF_LABELS = ['graphic-media']
+export const SELF_LABELS = [...ADULT_CONTENT_LABELS, ...OTHER_SELF_LABELS]
+
+export type AdultSelfLabel = (typeof ADULT_CONTENT_LABELS)[number]
+export type OtherSelfLabel = (typeof OTHER_SELF_LABELS)[number]
+export type SelfLabel = (typeof SELF_LABELS)[number]
+
 export function getModerationCauseKey(
   cause: ModerationCause | AppModerationCause,
 ): string {
diff --git a/src/view/com/composer/labels/LabelsBtn.tsx b/src/view/com/composer/labels/LabelsBtn.tsx
index d72366aea..a176426dc 100644
--- a/src/view/com/composer/labels/LabelsBtn.tsx
+++ b/src/view/com/composer/labels/LabelsBtn.tsx
@@ -1,44 +1,52 @@
 import React from 'react'
-import {Keyboard, LayoutAnimation, View} from 'react-native'
+import {Keyboard, View} from 'react-native'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
 import {ShieldExclamation} from '#/lib/icons'
+import {
+  ADULT_CONTENT_LABELS,
+  AdultSelfLabel,
+  OTHER_SELF_LABELS,
+  OtherSelfLabel,
+  SelfLabel,
+} from '#/lib/moderation'
+import {isWeb} from '#/platform/detection'
 import {atoms as a, useTheme} from '#/alf'
 import {Button, ButtonText} from '#/components/Button'
 import * as Dialog from '#/components/Dialog'
-import * as ToggleButton from '#/components/forms/ToggleButton'
+import * as Toggle from '#/components/forms/Toggle'
 import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
 import {Text} from '#/components/Typography'
-
-const ADULT_CONTENT_LABELS = ['sexual', 'nudity', 'porn']
-
 export function LabelsBtn({
   labels,
   hasMedia,
   onChange,
 }: {
-  labels: string[]
+  labels: SelfLabel[]
   hasMedia: boolean
-  onChange: (v: string[]) => void
+  onChange: (v: SelfLabel[]) => void
 }) {
   const control = Dialog.useDialogControl()
   const t = useTheme()
   const {_} = useLingui()
 
-  const removeAdultLabel = () => {
-    const final = labels.filter(l => !ADULT_CONTENT_LABELS.includes(l))
-    onChange(final)
-    LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
+  const hasLabel = labels.length > 0
+
+  const updateAdultLabels = (newLabels: AdultSelfLabel[]) => {
+    const newLabel = newLabels[newLabels.length - 1]
+    const filtered = labels.filter(l => !ADULT_CONTENT_LABELS.includes(l))
+    onChange([...filtered, newLabel].filter(Boolean) as SelfLabel[])
   }
 
-  const hasAdultSelection =
-    labels.includes('sexual') ||
-    labels.includes('nudity') ||
-    labels.includes('porn')
+  const updateOtherLabels = (newLabels: OtherSelfLabel[]) => {
+    const newLabel = newLabels[newLabels.length - 1]
+    const filtered = labels.filter(l => !OTHER_SELF_LABELS.includes(l))
+    onChange([...filtered, newLabel].filter(Boolean) as SelfLabel[])
+  }
 
-  if (!hasMedia && hasAdultSelection) {
-    removeAdultLabel()
+  if (!hasMedia && hasLabel) {
+    onChange([])
   }
 
   return (
@@ -64,10 +72,9 @@ export function LabelsBtn({
         <Dialog.Handle />
         <DialogInner
           labels={labels}
-          onChange={onChange}
-          hasAdultSelection={hasAdultSelection}
           hasMedia={hasMedia}
-          removeAdultLabel={removeAdultLabel}
+          updateAdultLabels={updateAdultLabels}
+          updateOtherLabels={updateOtherLabels}
         />
       </Dialog.Outer>
     </>
@@ -76,16 +83,14 @@ export function LabelsBtn({
 
 function DialogInner({
   labels,
-  onChange,
-  hasAdultSelection,
   hasMedia,
-  removeAdultLabel,
+  updateAdultLabels,
+  updateOtherLabels,
 }: {
   labels: string[]
-  onChange: (v: string[]) => void
-  hasAdultSelection: boolean
   hasMedia: boolean
-  removeAdultLabel: () => void
+  updateAdultLabels: (labels: AdultSelfLabel[]) => void
+  updateOtherLabels: (labels: OtherSelfLabel[]) => void
 }) {
   const {_} = useLingui()
   const control = Dialog.useDialogContext()
@@ -95,104 +100,153 @@ function DialogInner({
     <Dialog.ScrollableInner
       label={_(msg`Add a content warning`)}
       style={[{maxWidth: 500}, a.w_full]}>
-      <View style={[a.flex_1, a.gap_md]}>
-        <Text style={[a.text_2xl, a.font_bold]}>
-          <Trans>Add a content warning</Trans>
-        </Text>
-
-        <View
-          style={[
-            a.border,
-            a.p_md,
-            t.atoms.border_contrast_high,
-            a.rounded_md,
-          ]}>
-          <View
-            style={[a.flex_row, a.align_center, a.justify_between, a.pb_sm]}>
-            <Text style={[a.font_bold, a.text_lg]}>
-              <Trans>Adult Content</Trans>
-            </Text>
+      <View style={[a.flex_1]}>
+        <View style={[a.gap_sm]}>
+          <Text style={[a.text_2xl, a.font_bold]}>
+            <Trans>Add a content warning</Trans>
+          </Text>
+          <Text style={[t.atoms.text_contrast_medium, a.leading_snug]}>
+            {hasMedia ? (
+              <Trans>
+                Choose self-labels that are applicable for the media you are
+                posting. If none are selected, this post is suitable for all
+                audiences.
+              </Trans>
+            ) : (
+              <Trans>
+                There are no self-labels that can be applied to this post.
+              </Trans>
+            )}
+          </Text>
+        </View>
 
-            <Button
-              label={_(msg`Remove`)}
-              variant="ghost"
-              color="primary"
-              size="tiny"
-              onPress={removeAdultLabel}
-              disabled={!hasAdultSelection}
-              style={{opacity: hasAdultSelection ? 1 : 0}}
-              aria-hidden={!hasAdultSelection}>
-              <ButtonText>
-                <Trans>Remove</Trans>
-              </ButtonText>
-            </Button>
-          </View>
+        <View style={[a.my_md, a.gap_lg]}>
           {hasMedia ? (
             <>
-              <ToggleButton.Group
-                label={_(msg`Adult Content labels`)}
-                values={labels}
-                onChange={values => {
-                  onChange(values)
-                  LayoutAnimation.configureNext(
-                    LayoutAnimation.Presets.easeInEaseOut,
-                  )
-                }}>
-                <ToggleButton.Button name="sexual" label={_(msg`Suggestive`)}>
-                  <ToggleButton.ButtonText>
-                    <Trans>Suggestive</Trans>
-                  </ToggleButton.ButtonText>
-                </ToggleButton.Button>
-                <ToggleButton.Button name="nudity" label={_(msg`Nudity`)}>
-                  <ToggleButton.ButtonText>
-                    <Trans>Nudity</Trans>
-                  </ToggleButton.ButtonText>
-                </ToggleButton.Button>
-                <ToggleButton.Button name="porn" label={_(msg`Porn`)}>
-                  <ToggleButton.ButtonText>
-                    <Trans>Porn</Trans>
-                  </ToggleButton.ButtonText>
-                </ToggleButton.Button>
-              </ToggleButton.Group>
-
-              <Text style={[a.mt_sm, t.atoms.text_contrast_medium]}>
-                {labels.includes('sexual') ? (
-                  <Trans>Pictures meant for adults.</Trans>
-                ) : labels.includes('nudity') ? (
-                  <Trans>Artistic or non-erotic nudity.</Trans>
-                ) : labels.includes('porn') ? (
-                  <Trans>Sexual activity or erotic nudity.</Trans>
-                ) : (
-                  <Trans>If none are selected, suitable for all ages.</Trans>
-                )}
-              </Text>
+              <View>
+                <View
+                  style={[
+                    a.flex_row,
+                    a.align_center,
+                    a.justify_between,
+                    a.pb_sm,
+                  ]}>
+                  <Text style={[a.font_bold, a.text_lg]}>
+                    <Trans>Adult Content</Trans>
+                  </Text>
+                </View>
+                <View
+                  style={[
+                    a.p_md,
+                    a.rounded_sm,
+                    a.border,
+                    t.atoms.border_contrast_medium,
+                  ]}>
+                  <Toggle.Group
+                    label={_(msg`Adult Content labels`)}
+                    values={labels}
+                    onChange={values => {
+                      updateAdultLabels(values as AdultSelfLabel[])
+                    }}>
+                    <View style={[a.gap_sm]}>
+                      <Toggle.Item name="sexual" label={_(msg`Suggestive`)}>
+                        <Toggle.Radio />
+                        <Toggle.LabelText>
+                          <Trans>Suggestive</Trans>
+                        </Toggle.LabelText>
+                      </Toggle.Item>
+                      <Toggle.Item name="nudity" label={_(msg`Nudity`)}>
+                        <Toggle.Radio />
+                        <Toggle.LabelText>
+                          <Trans>Nudity</Trans>
+                        </Toggle.LabelText>
+                      </Toggle.Item>
+                      <Toggle.Item name="porn" label={_(msg`Porn`)}>
+                        <Toggle.Radio />
+                        <Toggle.LabelText>
+                          <Trans>Porn</Trans>
+                        </Toggle.LabelText>
+                      </Toggle.Item>
+                    </View>
+                  </Toggle.Group>
+                  <Text style={[a.mt_sm, t.atoms.text_contrast_medium]}>
+                    {labels.includes('sexual') ? (
+                      <Trans>Pictures meant for adults.</Trans>
+                    ) : labels.includes('nudity') ? (
+                      <Trans>Artistic or non-erotic nudity.</Trans>
+                    ) : labels.includes('porn') ? (
+                      <Trans>Sexual activity or erotic nudity.</Trans>
+                    ) : (
+                      <Trans>Does not contain adult content.</Trans>
+                    )}
+                  </Text>
+                </View>
+              </View>
+              <View>
+                <View
+                  style={[
+                    a.flex_row,
+                    a.align_center,
+                    a.justify_between,
+                    a.pb_sm,
+                  ]}>
+                  <Text style={[a.font_bold, a.text_lg]}>
+                    <Trans>Other</Trans>
+                  </Text>
+                </View>
+                <View
+                  style={[
+                    a.p_md,
+                    a.rounded_sm,
+                    a.border,
+                    t.atoms.border_contrast_medium,
+                  ]}>
+                  <Toggle.Group
+                    label={_(msg`Adult Content labels`)}
+                    values={labels}
+                    onChange={values => {
+                      updateOtherLabels(values as OtherSelfLabel[])
+                    }}>
+                    <Toggle.Item
+                      name="graphic-media"
+                      label={_(msg`Graphic Media`)}>
+                      <Toggle.Checkbox />
+                      <Toggle.LabelText>
+                        <Trans>Graphic Media</Trans>
+                      </Toggle.LabelText>
+                    </Toggle.Item>
+                  </Toggle.Group>
+                  <Text style={[a.mt_sm, t.atoms.text_contrast_medium]}>
+                    {labels.includes('graphic-media') ? (
+                      <Trans>
+                        Media that may be disturbing or inappropriate for some
+                        audiences.
+                      </Trans>
+                    ) : (
+                      <Trans>
+                        Does not contain graphic or disturbing content.
+                      </Trans>
+                    )}
+                  </Text>
+                </View>
+              </View>
             </>
-          ) : (
-            <View>
-              <Text style={t.atoms.text_contrast_medium}>
-                <Trans>
-                  <Text style={[a.font_bold, t.atoms.text_contrast_medium]}>
-                    Not Applicable.
-                  </Text>{' '}
-                  This warning is only available for posts with media attached.
-                </Trans>
-              </Text>
-            </View>
-          )}
+          ) : null}
         </View>
       </View>
 
-      <Button
-        label={_(msg`Done`)}
-        onPress={() => control.close()}
-        color="primary"
-        size="large"
-        variant="solid"
-        style={a.mt_xl}>
-        <ButtonText>
-          <Trans>Done</Trans>
-        </ButtonText>
-      </Button>
+      <View style={[a.mt_sm]}>
+        <Button
+          label={_(msg`Done`)}
+          onPress={() => control.close()}
+          color="primary"
+          size={isWeb ? 'small' : 'large'}
+          variant="solid">
+          <ButtonText>
+            <Trans>Done</Trans>
+          </ButtonText>
+        </Button>
+      </View>
     </Dialog.ScrollableInner>
   )
 }
diff --git a/src/view/com/composer/state/composer.ts b/src/view/com/composer/state/composer.ts
index e37690342..049488f3a 100644
--- a/src/view/com/composer/state/composer.ts
+++ b/src/view/com/composer/state/composer.ts
@@ -1,6 +1,7 @@
 import {ImagePickerAsset} from 'expo-image-picker'
 import {AppBskyFeedPostgate, RichText} from '@atproto/api'
 
+import {SelfLabel} from '#/lib/moderation'
 import {insertMentionAt} from '#/lib/strings/mention-manip'
 import {
   isBskyPostUrl,
@@ -48,7 +49,7 @@ export type EmbedDraft = {
 
 export type ComposerDraft = {
   richtext: RichText
-  labels: string[]
+  labels: SelfLabel[]
   postgate: AppBskyFeedPostgate.Record
   threadgate: ThreadgateAllowUISetting[]
   embed: EmbedDraft
@@ -56,7 +57,7 @@ export type ComposerDraft = {
 
 export type ComposerAction =
   | {type: 'update_richtext'; richtext: RichText}
-  | {type: 'update_labels'; labels: string[]}
+  | {type: 'update_labels'; labels: SelfLabel[]}
   | {type: 'update_postgate'; postgate: AppBskyFeedPostgate.Record}
   | {type: 'update_threadgate'; threadgate: ThreadgateAllowUISetting[]}
   | {type: 'embed_add_images'; images: ComposerImage[]}