diff options
Diffstat (limited to 'src/components/moderation')
-rw-r--r-- | src/components/moderation/ContentHider.tsx | 182 | ||||
-rw-r--r-- | src/components/moderation/GlobalModerationLabelPref.tsx | 93 | ||||
-rw-r--r-- | src/components/moderation/LabelsOnMe.tsx | 83 | ||||
-rw-r--r-- | src/components/moderation/LabelsOnMeDialog.tsx | 262 | ||||
-rw-r--r-- | src/components/moderation/ModerationDetailsDialog.tsx | 148 | ||||
-rw-r--r-- | src/components/moderation/ModerationLabelPref.tsx | 154 | ||||
-rw-r--r-- | src/components/moderation/PostAlerts.tsx | 66 | ||||
-rw-r--r-- | src/components/moderation/PostHider.tsx | 129 | ||||
-rw-r--r-- | src/components/moderation/ProfileHeaderAlerts.tsx | 66 | ||||
-rw-r--r-- | src/components/moderation/ScreenHider.tsx | 171 |
10 files changed, 1354 insertions, 0 deletions
diff --git a/src/components/moderation/ContentHider.tsx b/src/components/moderation/ContentHider.tsx new file mode 100644 index 000000000..1e8f36d31 --- /dev/null +++ b/src/components/moderation/ContentHider.tsx @@ -0,0 +1,182 @@ +import React from 'react' +import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native' +import {ModerationUI} from '@atproto/api' +import {useLingui} from '@lingui/react' +import {msg, Trans} from '@lingui/macro' + +import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription' +import {isJustAMute} from '#/lib/moderation' +import {sanitizeDisplayName} from '#/lib/strings/display-names' + +import {atoms as a, useTheme, useBreakpoints, web} from '#/alf' +import {Button} from '#/components/Button' +import {Text} from '#/components/Typography' +import { + ModerationDetailsDialog, + useModerationDetailsDialogControl, +} from '#/components/moderation/ModerationDetailsDialog' + +export function ContentHider({ + testID, + modui, + ignoreMute, + style, + childContainerStyle, + children, +}: React.PropsWithChildren<{ + testID?: string + modui: ModerationUI | undefined + ignoreMute?: boolean + style?: StyleProp<ViewStyle> + childContainerStyle?: StyleProp<ViewStyle> +}>) { + const t = useTheme() + const {_} = useLingui() + const {gtMobile} = useBreakpoints() + const [override, setOverride] = React.useState(false) + const control = useModerationDetailsDialogControl() + + const blur = modui?.blurs[0] + const desc = useModerationCauseDescription(blur) + + if (!blur || (ignoreMute && isJustAMute(modui))) { + return ( + <View testID={testID} style={[styles.outer, style]}> + {children} + </View> + ) + } + + return ( + <View testID={testID} style={[a.overflow_hidden, style]}> + <ModerationDetailsDialog control={control} modcause={blur} /> + + <Button + onPress={() => { + if (!modui.noOverride) { + setOverride(v => !v) + } else { + control.open() + } + }} + label={desc.name} + accessibilityHint={ + modui.noOverride + ? _(msg`Learn more about the moderation applied to this content.`) + : override + ? _(msg`Hide the content`) + : _(msg`Show the content`) + }> + {state => ( + <View + style={[ + a.flex_row, + a.w_full, + a.justify_start, + a.align_center, + a.py_md, + a.px_lg, + a.gap_xs, + a.rounded_sm, + t.atoms.bg_contrast_25, + gtMobile && [a.gap_sm, a.py_lg, a.mt_xs, a.px_xl], + (state.hovered || state.pressed) && t.atoms.bg_contrast_50, + ]}> + <desc.icon + size="md" + fill={t.atoms.text_contrast_medium.color} + style={{marginLeft: -2}} + /> + <Text + style={[ + a.flex_1, + a.text_left, + a.font_bold, + a.leading_snug, + gtMobile && [a.font_semibold], + t.atoms.text_contrast_medium, + web({ + marginBottom: 1, + }), + ]}> + {desc.name} + </Text> + {!modui.noOverride && ( + <Text + style={[ + a.font_bold, + a.leading_snug, + gtMobile && [a.font_semibold], + t.atoms.text_contrast_high, + web({ + marginBottom: 1, + }), + ]}> + {override ? <Trans>Hide</Trans> : <Trans>Show</Trans>} + </Text> + )} + </View> + )} + </Button> + + {desc.source && blur.type === 'label' && !override && ( + <Button + onPress={() => { + control.open() + }} + label={_( + msg`Learn more about the moderation applied to this content.`, + )} + style={[a.pt_sm]}> + {state => ( + <Text + style={[ + a.flex_1, + a.text_sm, + a.font_normal, + a.leading_snug, + t.atoms.text_contrast_medium, + a.text_left, + ]}> + {desc.sourceType === 'user' ? ( + <Trans>Labeled by the author.</Trans> + ) : ( + <Trans>Labeled by {sanitizeDisplayName(desc.source!)}.</Trans> + )}{' '} + <Text + style={[ + {color: t.palette.primary_500}, + a.text_sm, + state.hovered && [web({textDecoration: 'underline'})], + ]}> + <Trans>Learn more.</Trans> + </Text> + </Text> + )} + </Button> + )} + + {override && <View style={childContainerStyle}>{children}</View>} + </View> + ) +} + +const styles = StyleSheet.create({ + outer: { + overflow: 'hidden', + }, + cover: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + borderRadius: 8, + marginTop: 4, + paddingVertical: 14, + paddingLeft: 14, + paddingRight: 18, + }, + showBtn: { + marginLeft: 'auto', + alignSelf: 'center', + }, +}) diff --git a/src/components/moderation/GlobalModerationLabelPref.tsx b/src/components/moderation/GlobalModerationLabelPref.tsx new file mode 100644 index 000000000..7633cb9f2 --- /dev/null +++ b/src/components/moderation/GlobalModerationLabelPref.tsx @@ -0,0 +1,93 @@ +import React from 'react' +import {View} from 'react-native' +import {InterpretedLabelValueDefinition, LabelPreference} from '@atproto/api' +import {useLingui} from '@lingui/react' +import {msg} from '@lingui/macro' + +import {useGlobalLabelStrings} from '#/lib/moderation/useGlobalLabelStrings' +import { + usePreferencesQuery, + usePreferencesSetContentLabelMutation, +} from '#/state/queries/preferences' + +import {useTheme, atoms as a} from '#/alf' +import {Text} from '#/components/Typography' +import * as ToggleButton from '#/components/forms/ToggleButton' + +export function GlobalModerationLabelPref({ + labelValueDefinition, + disabled, +}: { + labelValueDefinition: InterpretedLabelValueDefinition + disabled?: boolean +}) { + const {_} = useLingui() + const t = useTheme() + + const {identifier} = labelValueDefinition + const {data: preferences} = usePreferencesQuery() + const {mutate, variables} = usePreferencesSetContentLabelMutation() + const savedPref = preferences?.moderationPrefs.labels[identifier] + const pref = variables?.visibility ?? savedPref ?? 'warn' + + const allLabelStrings = useGlobalLabelStrings() + const labelStrings = + labelValueDefinition.identifier in allLabelStrings + ? allLabelStrings[labelValueDefinition.identifier] + : { + name: labelValueDefinition.identifier, + description: `Labeled "${labelValueDefinition.identifier}"`, + } + + const labelOptions = { + hide: _(msg`Hide`), + warn: _(msg`Warn`), + ignore: _(msg`Show`), + } + + return ( + <View + style={[ + a.flex_row, + a.justify_between, + a.gap_sm, + a.py_md, + a.pl_lg, + a.pr_md, + a.align_center, + ]}> + <View style={[a.gap_xs, a.flex_1]}> + <Text style={[a.font_bold]}>{labelStrings.name}</Text> + <Text style={[t.atoms.text_contrast_medium, a.leading_snug]}> + {labelStrings.description} + </Text> + </View> + <View style={[a.justify_center, {minHeight: 35}]}> + {!disabled && ( + <ToggleButton.Group + label={_( + msg`Configure content filtering setting for category: ${labelStrings.name.toLowerCase()}`, + )} + values={[pref]} + onChange={newPref => + mutate({ + label: identifier, + visibility: newPref[0] as LabelPreference, + labelerDid: undefined, + }) + }> + <ToggleButton.Button name="ignore" label={labelOptions.ignore}> + {labelOptions.ignore} + </ToggleButton.Button> + <ToggleButton.Button name="warn" label={labelOptions.warn}> + {labelOptions.warn} + </ToggleButton.Button> + <ToggleButton.Button name="hide" label={labelOptions.hide}> + {labelOptions.hide} + </ToggleButton.Button> + </ToggleButton.Group> + )} + </View> + </View> + ) +} diff --git a/src/components/moderation/LabelsOnMe.tsx b/src/components/moderation/LabelsOnMe.tsx new file mode 100644 index 000000000..099769fa7 --- /dev/null +++ b/src/components/moderation/LabelsOnMe.tsx @@ -0,0 +1,83 @@ +import React from 'react' +import {StyleProp, View, ViewStyle} from 'react-native' +import {AppBskyFeedDefs, ComAtprotoLabelDefs} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useSession} from '#/state/session' + +import {atoms as a} from '#/alf' +import {Button, ButtonText, ButtonIcon, ButtonSize} from '#/components/Button' +import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' +import { + LabelsOnMeDialog, + useLabelsOnMeDialogControl, +} from '#/components/moderation/LabelsOnMeDialog' + +export function LabelsOnMe({ + details, + labels, + size, + style, +}: { + details: {did: string} | {uri: string; cid: string} + labels: ComAtprotoLabelDefs.Label[] | undefined + size?: ButtonSize + style?: StyleProp<ViewStyle> +}) { + const {_} = useLingui() + const {currentAccount} = useSession() + const isAccount = 'did' in details + const control = useLabelsOnMeDialogControl() + + if (!labels || !currentAccount) { + return null + } + labels = labels.filter( + l => !l.val.startsWith('!') && l.src !== currentAccount.did, + ) + if (!labels.length) { + return null + } + + const labelTarget = isAccount ? _(msg`account`) : _(msg`content`) + return ( + <View style={[a.flex_row, style]}> + <LabelsOnMeDialog control={control} subject={details} labels={labels} /> + + <Button + variant="solid" + color="secondary" + size={size || 'small'} + label={_(msg`View information about these labels`)} + onPress={() => { + control.open() + }}> + <ButtonIcon position="left" icon={CircleInfo} /> + <ButtonText style={[a.leading_snug]}> + {labels.length}{' '} + {labels.length === 1 ? ( + <Trans>label has been placed on this {labelTarget}</Trans> + ) : ( + <Trans>labels have been placed on this {labelTarget}</Trans> + )} + </ButtonText> + </Button> + </View> + ) +} + +export function LabelsOnMyPost({ + post, + style, +}: { + post: AppBskyFeedDefs.PostView + style?: StyleProp<ViewStyle> +}) { + const {currentAccount} = useSession() + if (post.author.did !== currentAccount?.did) { + return null + } + return ( + <LabelsOnMe details={post} labels={post.labels} size="tiny" style={style} /> + ) +} diff --git a/src/components/moderation/LabelsOnMeDialog.tsx b/src/components/moderation/LabelsOnMeDialog.tsx new file mode 100644 index 000000000..6eddbc7ce --- /dev/null +++ b/src/components/moderation/LabelsOnMeDialog.tsx @@ -0,0 +1,262 @@ +import React from 'react' +import {View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {ComAtprotoLabelDefs, ComAtprotoModerationDefs} from '@atproto/api' + +import {useLabelInfo} from '#/lib/moderation/useLabelInfo' +import {makeProfileLink} from '#/lib/routes/links' +import {sanitizeHandle} from '#/lib/strings/handles' +import {getAgent} from '#/state/session' + +import {atoms as a, useBreakpoints, useTheme} from '#/alf' +import {Text} from '#/components/Typography' +import * as Dialog from '#/components/Dialog' +import {Button, ButtonText} from '#/components/Button' +import {InlineLink} from '#/components/Link' +import * as Toast from '#/view/com/util/Toast' +import {Divider} from '../Divider' + +export {useDialogControl as useLabelsOnMeDialogControl} from '#/components/Dialog' + +type Subject = + | { + uri: string + cid: string + } + | { + did: string + } + +export interface LabelsOnMeDialogProps { + control: Dialog.DialogOuterProps['control'] + subject: Subject + labels: ComAtprotoLabelDefs.Label[] +} + +export function LabelsOnMeDialogInner(props: LabelsOnMeDialogProps) { + const {_} = useLingui() + const [appealingLabel, setAppealingLabel] = React.useState< + ComAtprotoLabelDefs.Label | undefined + >(undefined) + const {subject, labels} = props + const isAccount = 'did' in subject + + return ( + <Dialog.ScrollableInner + label={ + isAccount + ? _(msg`The following labels were applied to your account.`) + : _(msg`The following labels were applied to your content.`) + }> + {appealingLabel ? ( + <AppealForm + label={appealingLabel} + subject={subject} + control={props.control} + onPressBack={() => setAppealingLabel(undefined)} + /> + ) : ( + <> + <Text style={[a.text_2xl, a.font_bold, a.pb_xs, a.leading_tight]}> + {isAccount ? ( + <Trans>Labels on your account</Trans> + ) : ( + <Trans>Labels on your content</Trans> + )} + </Text> + <Text style={[a.text_md, a.leading_snug]}> + <Trans> + You may appeal these labels if you feel they were placed in error. + </Trans> + </Text> + + <View style={[a.py_lg, a.gap_md]}> + {labels.map(label => ( + <Label + key={`${label.val}-${label.src}`} + label={label} + control={props.control} + onPressAppeal={label => setAppealingLabel(label)} + /> + ))} + </View> + </> + )} + + <Dialog.Close /> + </Dialog.ScrollableInner> + ) +} + +export function LabelsOnMeDialog(props: LabelsOnMeDialogProps) { + return ( + <Dialog.Outer control={props.control}> + <Dialog.Handle /> + + <LabelsOnMeDialogInner {...props} /> + </Dialog.Outer> + ) +} + +function Label({ + label, + control, + onPressAppeal, +}: { + label: ComAtprotoLabelDefs.Label + control: Dialog.DialogOuterProps['control'] + onPressAppeal: (label: ComAtprotoLabelDefs.Label) => void +}) { + const t = useTheme() + const {_} = useLingui() + const {labeler, strings} = useLabelInfo(label) + return ( + <View + style={[ + a.border, + t.atoms.border_contrast_low, + a.rounded_sm, + a.overflow_hidden, + ]}> + <View style={[a.p_md, a.gap_sm, a.flex_row]}> + <View style={[a.flex_1, a.gap_xs]}> + <Text style={[a.font_bold, a.text_md]}>{strings.name}</Text> + <Text style={[t.atoms.text_contrast_medium, a.leading_snug]}> + {strings.description} + </Text> + </View> + <View> + <Button + variant="solid" + color="secondary" + size="small" + label={_(msg`Appeal`)} + onPress={() => onPressAppeal(label)}> + <ButtonText> + <Trans>Appeal</Trans> + </ButtonText> + </Button> + </View> + </View> + + <Divider /> + + <View style={[a.px_md, a.py_sm, t.atoms.bg_contrast_25]}> + <Text style={[t.atoms.text_contrast_medium]}> + <Trans>Source:</Trans>{' '} + <InlineLink + to={makeProfileLink( + labeler ? labeler.creator : {did: label.src, handle: ''}, + )} + onPress={() => control.close()}> + {labeler ? sanitizeHandle(labeler.creator.handle, '@') : label.src} + </InlineLink> + </Text> + </View> + </View> + ) +} + +function AppealForm({ + label, + subject, + control, + onPressBack, +}: { + label: ComAtprotoLabelDefs.Label + subject: Subject + control: Dialog.DialogOuterProps['control'] + onPressBack: () => void +}) { + const {_} = useLingui() + const {labeler, strings} = useLabelInfo(label) + const {gtMobile} = useBreakpoints() + const [details, setDetails] = React.useState('') + const isAccountReport = 'did' in subject + + const onSubmit = async () => { + try { + const $type = !isAccountReport + ? 'com.atproto.repo.strongRef' + : 'com.atproto.admin.defs#repoRef' + await getAgent() + .withProxy('atproto_labeler', label.src) + .createModerationReport({ + reasonType: ComAtprotoModerationDefs.REASONAPPEAL, + subject: { + $type, + ...subject, + }, + reason: details, + }) + Toast.show(_(msg`Appeal submitted.`)) + } finally { + control.close() + } + } + + return ( + <> + <Text style={[a.text_2xl, a.font_bold, a.pb_xs, a.leading_tight]}> + <Trans>Appeal "{strings.name}" label</Trans> + </Text> + <Text style={[a.text_md, a.leading_snug]}> + <Trans> + This appeal will be sent to{' '} + <InlineLink + to={makeProfileLink( + labeler ? labeler.creator : {did: label.src, handle: ''}, + )} + onPress={() => control.close()} + style={[a.text_md, a.leading_snug]}> + {labeler ? sanitizeHandle(labeler.creator.handle, '@') : label.src} + </InlineLink> + . + </Trans> + </Text> + <View style={[a.my_md]}> + <Dialog.Input + label={_(msg`Text input field`)} + placeholder={_( + msg`Please explain why you think this label was incorrectly applied by ${ + labeler ? sanitizeHandle(labeler.creator.handle, '@') : label.src + }`, + )} + value={details} + onChangeText={setDetails} + autoFocus={true} + numberOfLines={3} + multiline + maxLength={300} + /> + </View> + + <View + style={ + gtMobile + ? [a.flex_row, a.justify_between] + : [{flexDirection: 'column-reverse'}, a.gap_sm] + }> + <Button + testID="backBtn" + variant="solid" + color="secondary" + size="medium" + onPress={onPressBack} + label={_(msg`Back`)}> + {_(msg`Back`)} + </Button> + <Button + testID="submitBtn" + variant="solid" + color="primary" + size="medium" + onPress={onSubmit} + label={_(msg`Submit`)}> + {_(msg`Submit`)} + </Button> + </View> + </> + ) +} diff --git a/src/components/moderation/ModerationDetailsDialog.tsx b/src/components/moderation/ModerationDetailsDialog.tsx new file mode 100644 index 000000000..da490cb43 --- /dev/null +++ b/src/components/moderation/ModerationDetailsDialog.tsx @@ -0,0 +1,148 @@ +import React from 'react' +import {View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {ModerationCause} from '@atproto/api' + +import {listUriToHref} from '#/lib/strings/url-helpers' +import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription' +import {makeProfileLink} from '#/lib/routes/links' + +import {isNative} from '#/platform/detection' +import {useTheme, atoms as a} from '#/alf' +import {Text} from '#/components/Typography' +import * as Dialog from '#/components/Dialog' +import {InlineLink} from '#/components/Link' +import {Divider} from '#/components/Divider' + +export {useDialogControl as useModerationDetailsDialogControl} from '#/components/Dialog' + +export interface ModerationDetailsDialogProps { + control: Dialog.DialogOuterProps['control'] + modcause: ModerationCause +} + +export function ModerationDetailsDialog(props: ModerationDetailsDialogProps) { + return ( + <Dialog.Outer control={props.control}> + <Dialog.Handle /> + <ModerationDetailsDialogInner {...props} /> + </Dialog.Outer> + ) +} + +function ModerationDetailsDialogInner({ + modcause, + control, +}: ModerationDetailsDialogProps & { + control: Dialog.DialogOuterProps['control'] +}) { + const t = useTheme() + const {_} = useLingui() + const desc = useModerationCauseDescription(modcause) + + let name + let description + if (!modcause) { + name = _(msg`Content Warning`) + description = _( + msg`Moderator has chosen to set a general warning on the content.`, + ) + } else if (modcause.type === 'blocking') { + if (modcause.source.type === 'list') { + const list = modcause.source.list + name = _(msg`User Blocked by List`) + description = ( + <Trans> + This user is included in the{' '} + <InlineLink to={listUriToHref(list.uri)} style={[a.text_sm]}> + {list.name} + </InlineLink>{' '} + list which you have blocked. + </Trans> + ) + } else { + name = _(msg`User Blocked`) + description = _( + msg`You have blocked this user. You cannot view their content.`, + ) + } + } else if (modcause.type === 'blocked-by') { + name = _(msg`User Blocks You`) + description = _( + msg`This user has blocked you. You cannot view their content.`, + ) + } else if (modcause.type === 'block-other') { + name = _(msg`Content Not Available`) + description = _( + msg`This content is not available because one of the users involved has blocked the other.`, + ) + } else if (modcause.type === 'muted') { + if (modcause.source.type === 'list') { + const list = modcause.source.list + name = _(msg`Account Muted by List`) + description = ( + <Trans> + This user is included in the{' '} + <InlineLink to={listUriToHref(list.uri)} style={[a.text_sm]}> + {list.name} + </InlineLink>{' '} + list which you have muted. + </Trans> + ) + } else { + name = _(msg`Account Muted`) + description = _(msg`You have muted this account.`) + } + } else if (modcause.type === 'mute-word') { + name = _(msg`Post Hidden by Muted Word`) + description = _(msg`You've chosen to hide a word or tag within this post.`) + } else if (modcause.type === 'hidden') { + name = _(msg`Post Hidden by You`) + description = _(msg`You have hidden this post.`) + } else if (modcause.type === 'label') { + name = desc.name + description = desc.description + } else { + // should never happen + name = '' + description = '' + } + + return ( + <Dialog.ScrollableInner label={_(msg`Moderation details`)}> + <Text style={[t.atoms.text, a.text_2xl, a.font_bold, a.mb_sm]}> + {name} + </Text> + <Text style={[t.atoms.text, a.text_md, a.mb_lg, a.leading_snug]}> + {description} + </Text> + + {modcause.type === 'label' && ( + <> + <Divider /> + <Text style={[t.atoms.text, a.text_md, a.leading_snug, a.mt_lg]}> + <Trans> + This label was applied by{' '} + {modcause.source.type === 'user' ? ( + <Trans>the author</Trans> + ) : ( + <InlineLink + to={makeProfileLink({did: modcause.label.src, handle: ''})} + onPress={() => control.close()} + style={a.text_md}> + {desc.source} + </InlineLink> + )} + . + </Trans> + </Text> + </> + )} + + {isNative && <View style={{height: 40}} />} + + <Dialog.Close /> + </Dialog.ScrollableInner> + ) +} diff --git a/src/components/moderation/ModerationLabelPref.tsx b/src/components/moderation/ModerationLabelPref.tsx new file mode 100644 index 000000000..f14550488 --- /dev/null +++ b/src/components/moderation/ModerationLabelPref.tsx @@ -0,0 +1,154 @@ +import React from 'react' +import {View} from 'react-native' +import {InterpretedLabelValueDefinition, LabelPreference} from '@atproto/api' +import {useLingui} from '@lingui/react' +import {msg, Trans} from '@lingui/macro' + +import {useGlobalLabelStrings} from '#/lib/moderation/useGlobalLabelStrings' +import {useLabelBehaviorDescription} from '#/lib/moderation/useLabelBehaviorDescription' +import { + usePreferencesQuery, + usePreferencesSetContentLabelMutation, +} from '#/state/queries/preferences' +import {getLabelStrings} from '#/lib/moderation/useLabelInfo' + +import {useTheme, atoms as a} from '#/alf' +import {Text} from '#/components/Typography' +import {InlineLink} from '#/components/Link' +import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '../icons/CircleInfo' +import * as ToggleButton from '#/components/forms/ToggleButton' + +export function ModerationLabelPref({ + labelValueDefinition, + labelerDid, + disabled, +}: { + labelValueDefinition: InterpretedLabelValueDefinition + labelerDid: string | undefined + disabled?: boolean +}) { + const {_, i18n} = useLingui() + const t = useTheme() + + const isGlobalLabel = !labelValueDefinition.definedBy + const {identifier} = labelValueDefinition + const {data: preferences} = usePreferencesQuery() + const {mutate, variables} = usePreferencesSetContentLabelMutation() + const savedPref = + labelerDid && !isGlobalLabel + ? preferences?.moderationPrefs.labelers.find(l => l.did === labelerDid) + ?.labels[identifier] + : preferences?.moderationPrefs.labels[identifier] + const pref = + variables?.visibility ?? + savedPref ?? + labelValueDefinition.defaultSetting ?? + 'warn' + + // does the 'warn' setting make sense for this label? + const canWarn = !( + labelValueDefinition.blurs === 'none' && + labelValueDefinition.severity === 'none' + ) + // is this label adult only? + const adultOnly = labelValueDefinition.flags.includes('adult') + // is this label disabled because it's adult only? + const adultDisabled = + adultOnly && !preferences?.moderationPrefs.adultContentEnabled + // are there any reasons we cant configure this label here? + const cantConfigure = isGlobalLabel || adultDisabled + + // adjust the pref based on whether warn is available + let prefAdjusted = pref + if (adultDisabled) { + prefAdjusted = 'hide' + } else if (!canWarn && pref === 'warn') { + prefAdjusted = 'ignore' + } + + // grab localized descriptions of the label and its settings + const currentPrefLabel = useLabelBehaviorDescription( + labelValueDefinition, + prefAdjusted, + ) + const hideLabel = useLabelBehaviorDescription(labelValueDefinition, 'hide') + const warnLabel = useLabelBehaviorDescription(labelValueDefinition, 'warn') + const ignoreLabel = useLabelBehaviorDescription( + labelValueDefinition, + 'ignore', + ) + const globalLabelStrings = useGlobalLabelStrings() + const labelStrings = getLabelStrings( + i18n.locale, + globalLabelStrings, + labelValueDefinition, + ) + + return ( + <View style={[a.flex_row, a.gap_sm, a.px_lg, a.py_lg, a.justify_between]}> + <View style={[a.gap_xs, a.flex_1]}> + <Text style={[a.font_bold]}>{labelStrings.name}</Text> + <Text style={[t.atoms.text_contrast_medium, a.leading_snug]}> + {labelStrings.description} + </Text> + + {cantConfigure && ( + <View style={[a.flex_row, a.gap_xs, a.align_center, a.mt_xs]}> + <CircleInfo size="sm" fill={t.atoms.text_contrast_high.color} /> + + <Text + style={[t.atoms.text_contrast_medium, a.font_semibold, a.italic]}> + {adultDisabled ? ( + <Trans>Adult content is disabled.</Trans> + ) : isGlobalLabel ? ( + <Trans> + Configured in{' '} + <InlineLink to="/moderation" style={a.text_sm}> + moderation settings + </InlineLink> + . + </Trans> + ) : null} + </Text> + </View> + )} + </View> + {disabled ? ( + <></> + ) : cantConfigure ? ( + <View style={[{minHeight: 35}, a.px_sm, a.py_md]}> + <Text style={[a.font_bold, t.atoms.text_contrast_medium]}> + {currentPrefLabel} + </Text> + </View> + ) : ( + <View style={[{minHeight: 35}]}> + <ToggleButton.Group + label={_( + msg`Configure content filtering setting for category: ${labelStrings.name.toLowerCase()}`, + )} + values={[prefAdjusted]} + onChange={newPref => + mutate({ + label: identifier, + visibility: newPref[0] as LabelPreference, + labelerDid, + }) + }> + <ToggleButton.Button name="ignore" label={ignoreLabel}> + {ignoreLabel} + </ToggleButton.Button> + {canWarn && ( + <ToggleButton.Button name="warn" label={warnLabel}> + {warnLabel} + </ToggleButton.Button> + )} + <ToggleButton.Button name="hide" label={hideLabel}> + {hideLabel} + </ToggleButton.Button> + </ToggleButton.Group> + </View> + )} + </View> + ) +} diff --git a/src/components/moderation/PostAlerts.tsx b/src/components/moderation/PostAlerts.tsx new file mode 100644 index 000000000..0bfe69678 --- /dev/null +++ b/src/components/moderation/PostAlerts.tsx @@ -0,0 +1,66 @@ +import React from 'react' +import {StyleProp, View, ViewStyle} from 'react-native' +import {ModerationUI, ModerationCause} from '@atproto/api' + +import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription' +import {getModerationCauseKey} from '#/lib/moderation' + +import {atoms as a} from '#/alf' +import {Button, ButtonText, ButtonIcon} from '#/components/Button' +import { + ModerationDetailsDialog, + useModerationDetailsDialogControl, +} from '#/components/moderation/ModerationDetailsDialog' + +export function PostAlerts({ + modui, + style, +}: { + modui: ModerationUI + includeMute?: boolean + style?: StyleProp<ViewStyle> +}) { + if (!modui.alert && !modui.inform) { + return null + } + + return ( + <View style={[a.flex_col, a.gap_xs, style]}> + <View style={[a.flex_row, a.flex_wrap, a.gap_xs]}> + {modui.alerts.map(cause => ( + <PostLabel key={getModerationCauseKey(cause)} cause={cause} /> + ))} + {modui.informs.map(cause => ( + <PostLabel key={getModerationCauseKey(cause)} cause={cause} /> + ))} + </View> + </View> + ) +} + +function PostLabel({cause}: {cause: ModerationCause}) { + const control = useModerationDetailsDialogControl() + const desc = useModerationCauseDescription(cause) + + return ( + <> + <Button + label={desc.name} + variant="solid" + color="secondary" + size="small" + shape="default" + onPress={() => { + control.open() + }} + style={[a.px_sm, a.py_xs, a.gap_xs]}> + <ButtonIcon icon={desc.icon} position="left" /> + <ButtonText style={[a.text_left, a.leading_snug]}> + {desc.name} + </ButtonText> + </Button> + + <ModerationDetailsDialog control={control} modcause={cause} /> + </> + ) +} diff --git a/src/components/moderation/PostHider.tsx b/src/components/moderation/PostHider.tsx new file mode 100644 index 000000000..464ee2077 --- /dev/null +++ b/src/components/moderation/PostHider.tsx @@ -0,0 +1,129 @@ +import React, {ComponentProps} from 'react' +import {StyleSheet, Pressable, View, ViewStyle, StyleProp} from 'react-native' +import {ModerationUI} from '@atproto/api' +import {useLingui} from '@lingui/react' +import {Trans, msg} from '@lingui/macro' + +import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription' +import {addStyle} from 'lib/styles' + +import {useTheme, atoms as a} from '#/alf' +import { + ModerationDetailsDialog, + useModerationDetailsDialogControl, +} from '#/components/moderation/ModerationDetailsDialog' +import {Text} from '#/components/Typography' +// import {Link} from '#/components/Link' TODO this imposes some styles that screw things up +import {Link} from '#/view/com/util/Link' + +interface Props extends ComponentProps<typeof Link> { + iconSize: number + iconStyles: StyleProp<ViewStyle> + modui: ModerationUI +} + +export function PostHider({ + testID, + href, + modui, + style, + children, + iconSize, + iconStyles, + ...props +}: Props) { + const t = useTheme() + const {_} = useLingui() + const [override, setOverride] = React.useState(false) + const control = useModerationDetailsDialogControl() + const blur = modui.blurs[0] + const desc = useModerationCauseDescription(blur) + + if (!blur) { + return ( + <Link + testID={testID} + style={style} + href={href} + accessible={false} + {...props}> + {children} + </Link> + ) + } + + return !override ? ( + <Pressable + onPress={() => { + if (!modui.noOverride) { + setOverride(v => !v) + } + }} + accessibilityRole="button" + accessibilityHint={ + override ? _(msg`Hide the content`) : _(msg`Show the content`) + } + accessibilityLabel="" + style={[ + a.flex_row, + a.align_center, + a.gap_sm, + a.py_md, + { + paddingLeft: 6, + paddingRight: 18, + }, + override ? {paddingBottom: 0} : undefined, + t.atoms.bg, + ]}> + <ModerationDetailsDialog control={control} modcause={blur} /> + <Pressable + onPress={() => { + control.open() + }} + accessibilityRole="button" + accessibilityLabel={_(msg`Learn more about this warning`)} + accessibilityHint=""> + <View + style={[ + t.atoms.bg_contrast_25, + a.align_center, + a.justify_center, + { + width: iconSize, + height: iconSize, + borderRadius: iconSize, + }, + iconStyles, + ]}> + <desc.icon size="sm" fill={t.atoms.text_contrast_medium.color} /> + </View> + </Pressable> + <Text style={[t.atoms.text_contrast_medium, a.flex_1]} numberOfLines={1}> + {desc.name} + </Text> + {!modui.noOverride && ( + <Text style={[{color: t.palette.primary_500}]}> + {override ? <Trans>Hide</Trans> : <Trans>Show</Trans>} + </Text> + )} + </Pressable> + ) : ( + <Link + testID={testID} + style={addStyle(style, styles.child)} + href={href} + accessible={false} + {...props}> + {children} + </Link> + ) +} + +const styles = StyleSheet.create({ + child: { + borderWidth: 0, + borderTopWidth: 0, + borderRadius: 8, + }, +}) diff --git a/src/components/moderation/ProfileHeaderAlerts.tsx b/src/components/moderation/ProfileHeaderAlerts.tsx new file mode 100644 index 000000000..dfc2aa557 --- /dev/null +++ b/src/components/moderation/ProfileHeaderAlerts.tsx @@ -0,0 +1,66 @@ +import React from 'react' +import {StyleProp, View, ViewStyle} from 'react-native' +import {ModerationCause, ModerationDecision} from '@atproto/api' + +import {getModerationCauseKey} from 'lib/moderation' +import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription' + +import {atoms as a} from '#/alf' +import {Button, ButtonText, ButtonIcon} from '#/components/Button' +import { + ModerationDetailsDialog, + useModerationDetailsDialogControl, +} from '#/components/moderation/ModerationDetailsDialog' + +export function ProfileHeaderAlerts({ + moderation, + style, +}: { + moderation: ModerationDecision + style?: StyleProp<ViewStyle> +}) { + const modui = moderation.ui('profileView') + if (!modui.alert && !modui.inform) { + return null + } + + return ( + <View style={[a.flex_col, a.gap_xs, style]}> + <View style={[a.flex_row, a.flex_wrap, a.gap_xs]}> + {modui.alerts.map(cause => ( + <ProfileLabel key={getModerationCauseKey(cause)} cause={cause} /> + ))} + {modui.informs.map(cause => ( + <ProfileLabel key={getModerationCauseKey(cause)} cause={cause} /> + ))} + </View> + </View> + ) +} + +function ProfileLabel({cause}: {cause: ModerationCause}) { + const control = useModerationDetailsDialogControl() + const desc = useModerationCauseDescription(cause) + + return ( + <> + <Button + label={desc.name} + variant="solid" + color="secondary" + size="small" + shape="default" + onPress={() => { + control.open() + }} + style={[a.px_sm, a.py_xs, a.gap_xs]}> + <ButtonIcon icon={desc.icon} position="left" /> + <ButtonText style={[a.text_left, a.leading_snug]}> + {desc.name} + </ButtonText> + </Button> + + <ModerationDetailsDialog control={control} modcause={cause} /> + </> + ) +} diff --git a/src/components/moderation/ScreenHider.tsx b/src/components/moderation/ScreenHider.tsx new file mode 100644 index 000000000..71ca85a92 --- /dev/null +++ b/src/components/moderation/ScreenHider.tsx @@ -0,0 +1,171 @@ +import React from 'react' +import { + TouchableWithoutFeedback, + StyleProp, + View, + ViewStyle, +} from 'react-native' +import {useNavigation} from '@react-navigation/native' +import {ModerationUI} from '@atproto/api' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' +import {NavigationProp} from 'lib/routes/types' +import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription' + +import {useTheme, atoms as a} from '#/alf' +import {CenteredView} from '#/view/com/util/Views' +import {Text} from '#/components/Typography' +import {Button, ButtonText} from '#/components/Button' +import { + ModerationDetailsDialog, + useModerationDetailsDialogControl, +} from '#/components/moderation/ModerationDetailsDialog' + +export function ScreenHider({ + testID, + screenDescription, + modui, + style, + containerStyle, + children, +}: React.PropsWithChildren<{ + testID?: string + screenDescription: string + modui: ModerationUI + style?: StyleProp<ViewStyle> + containerStyle?: StyleProp<ViewStyle> +}>) { + const t = useTheme() + const {_} = useLingui() + const [override, setOverride] = React.useState(false) + const navigation = useNavigation<NavigationProp>() + const {isMobile} = useWebMediaQueries() + const control = useModerationDetailsDialogControl() + const blur = modui.blurs[0] + const desc = useModerationCauseDescription(blur) + + if (!blur || override) { + return ( + <View testID={testID} style={style}> + {children} + </View> + ) + } + + const isNoPwi = !!modui.blurs.find( + cause => + cause.type === 'label' && cause.labelDef.id === '!no-unauthenticated', + ) + return ( + <CenteredView + style={[ + a.flex_1, + { + paddingTop: 100, + paddingBottom: 150, + }, + t.atoms.bg, + containerStyle, + ]} + sideBorders> + <View style={[a.align_center, a.mb_md]}> + <View + style={[ + t.atoms.bg_contrast_975, + a.align_center, + a.justify_center, + { + borderRadius: 25, + width: 50, + height: 50, + }, + ]}> + <desc.icon width={24} fill={t.atoms.bg.backgroundColor} /> + </View> + </View> + <Text + style={[ + a.text_4xl, + a.font_semibold, + a.text_center, + a.mb_md, + t.atoms.text, + ]}> + {isNoPwi ? ( + <Trans>Sign-in Required</Trans> + ) : ( + <Trans>Content Warning</Trans> + )} + </Text> + <Text + style={[ + a.text_lg, + a.mb_md, + a.px_lg, + a.text_center, + t.atoms.text_contrast_medium, + ]}> + {isNoPwi ? ( + <Trans> + This account has requested that users sign in to view their profile. + </Trans> + ) : ( + <> + <Trans>This {screenDescription} has been flagged:</Trans> + <Text style={[a.text_lg, a.font_semibold, t.atoms.text, a.ml_xs]}> + {desc.name}.{' '} + </Text> + <TouchableWithoutFeedback + onPress={() => { + control.open() + }} + accessibilityRole="button" + accessibilityLabel={_(msg`Learn more about this warning`)} + accessibilityHint=""> + <Text style={[a.text_lg, {color: t.palette.primary_500}]}> + <Trans>Learn More</Trans> + </Text> + </TouchableWithoutFeedback> + + <ModerationDetailsDialog control={control} modcause={blur} /> + </> + )}{' '} + </Text> + {isMobile && <View style={a.flex_1} />} + <View style={[a.flex_row, a.justify_center, a.my_md, a.gap_md]}> + <Button + variant="solid" + color="primary" + size="large" + style={[a.rounded_full]} + label={_(msg`Go back`)} + onPress={() => { + if (navigation.canGoBack()) { + navigation.goBack() + } else { + navigation.navigate('Home') + } + }}> + <ButtonText> + <Trans>Go back</Trans> + </ButtonText> + </Button> + {!modui.noOverride && ( + <Button + variant="solid" + color="secondary" + size="large" + style={[a.rounded_full]} + label={_(msg`Show anyway`)} + onPress={() => setOverride(v => !v)}> + <ButtonText> + <Trans>Show anyway</Trans> + </ButtonText> + </Button> + )} + </View> + </CenteredView> + ) +} |