3p moderation services [WIP] (#2550)

* Add modservice screen and profile-header-card

* Drop the guidelines for now

* Remove ununsed constants

* Add label & label group descriptions

* Not found state

* Reorg, add icon

* Subheader

* Header

* Complete header

* Clean up

* Add all groups

* Fix scroll view

* Dialogs side quest

* Remove log

* Add (WIP) debug mod page

* Dialog solution

* Add note

* Clean up and reorganize localized moderation strings

* Memoize

* Add example

* Add first ReportDialog screen

* Report dialog step 2

* Submit

* Integrate updates

* Move moderation screen

* Migrate buttons

* Migrate everything

* Rough sketch

* Fix types

* Update atoms values

* Abstract ModerationServiceCard

* Hook up data to settings page

* Handle subscription

* Rough enablement

* Rough enablement

* Some validation, fixes

* More work on the mod debug screen

* Hook up data

* Update invalidation

* Hook up data to ReportDialog

* Fix native error

* Refactor/rewrite the entire moderation-application system

* Fix toggles

* Add copyright and other option to report

* Handle reports on profile vs content

* Little cleanup

* Get post hiding back in gear

* Better loading flow on Mod screen

* Clean up Mod screen

* Clean up ProfileMod screen

* Handle muting correctly

* Update enablement on ProfileMod screen

* Improve Moderation screen and dialog

* Styling, handle disabled labelers

* Rework list of labels on own content

* Use moderateNotification()

* ReportDialog updates

* Fix button overflow

* Simplify the ProfileModerationService ui

* Mod screen design

* Move moderation card from the profile header to a tab

* Small tweaks to the moderation screen

* Enable toggle on mod page

* Add notifs to debugmod and dont filter notifs from followed users

* Add moderator-service profile view

* Wire up more of the modservice data to profiles

* A bunch of speculative non-working UI

* Cleanup: delete old code

* Update ModerationDetailsDialog

* Update ReportDialog

* Update LabelsOnMe dialog

* Handle ReportDialog load better

* Rename LabelsOnMeDialog, fix close

* Experiment to put labeling under a tab of a normal profile

* Moderator variation of profile

* Remove dead code and start moving toward latest modsdk

* Remove a bunch of now-dead label strings

* Update ModDebug to be a bit more intuitive and support custom labels

* Minor ui tweaks

* Improve consistency of display name blurring

* Fix profile-card warning rendering

* More debugmod UI tuning

* Update to use new labeler semantics

* Delete some dead code and do some refactoring

* Update profile to pull from labeler definition

* Implement new label config controls (wip)

* Tweak ui

* Implement preference controls on labelers

* Rework label pref ui

* Get moderation screen working

* Add asyncstorage query persistence

* Implement label handling

* Small cleanup

* Implement Likes dialog

* Fix: remove text outside of text element

* Cleanup

* Fix likes dialog on mobile

* Implement the label appeal flow

* Get report flow working again with temporarily fixed report options

* Update onboarding

* Enforce limit of ten labeler subscriptions

* Fix type errors

* Fix lint errors

* Improve types of RQ

* Some work on Likes dialog, needs discussion

* Bit of ReportDialog cleanup

* Replace non-single-path SVG

* Update nudity descriptions

* Update to use new sdk updates

* Add adult-content-enabled behavior to label config

* Use the default setting of custom labels

* Handle global moderation label prefs with the global settings

* Fix missing postAuthor

* Fix empty moderation page

* Add mutewords control back to Mod screen

* Tweak adult setting styles

* Remove deprecated global labels

* Handle underage users on mod screen

* Adjust font sizes

* Swap in RichText

* Like button improvements

* Tweaks to Labeler profile

* Design tweaks for mod pref dialog

* Add tertiary button color

* Switch moderation UIs to tertiary color

* Update mutewords and hiddenposts to use the new sdk

* Add test-environment mod authority

* Switch 'gore' to 'graphic-media'

* Move nudity out of the adult content control

* Remove focus styles from buttons - let the browser behavior handle it

* Fixes to the adult content age-gating in moderaiton

* Ditch tertiary button color, lighten secondary button

* Fix some colors

* Remove focused overrides from toggles

* Liked by screen

* Rework the moderationlabelpref

* Fix optimistic like

* Cleanup

* Change how onboarding handles adult content enabled/disabled

* Add special handling of the mod authorities

* Tweaks

* Update the default labeler avatar to a shield

* Add route to go server

* Avoid dups due to bad config

* Fix attrs

* Fix: dont try to detect link/label mismatches on post meta

* Correctly show the label behavior when adult content is disabled

* Readd the local hiddenPosts handling

* WIP

* Fix bad merge

* Conten hider design tweaks

* Fix text string breakage

* Adjust source text in ContentHider

* Fix link bug

* Design tweaks to ContentHider and ModDetailsDialog

* Adjust spacing of inform badges

* Adjust spacing of embeds in posts

* Style tweaks to post/profile alerts

* Labels on me and dialog

* Remove bad focus styles from post dropdown

* Better spacing solution

* Tune moderation UIs

* Moderation UI tweaks for mobile

* Move labelers query on Mod screen

* Update to use new SDK appLabelers semantics

* Implement report submission

* Replace the report modal entirely with the report dialog

* Add @ to mod details dialog handle

* Bump SDK package

* Remove silly type

* Add to AWS build CI

* Fix ToggleButton overflow

* Clean up ModServiceCard, rename to LabelingServiceCard

* Hackfix to translate gore labels to graphic-media

* Tune content hider sizing on web desktop

* Handle self labels

* Fix spacing below text-only posts

* Fix: send appeals to the right labeler

* Give mod page links interactive states

* Fix references

* Remove focus handling

* Remove remnant

* Remove the like count from the subscribed labeler listing

* Bump @atproto/api@0.11.1

* Remove extra @

* Fix: persist labels to local storage to reduce coverage gaps

* update dipendencies

* revert dipendencies

* Add some explainers on how blocking affects labelers

* Tweak copy

* Fix underline color in header

* Fix profile menu

* Handle card overflow

* Remove metrics from header

* Mute 'account' not 'user'

* Show metrics if self

* Show the labels tab on logged out view

* Fix bad merge

* Use purple theming on labelers

* Tighten space on LabelerCard

* Set staleTime to 6hrs for labeler details

* Memoize the memoizers

* Drop staleTime to 60s

* Move label defs into a context to reduce recomputes

* Submit view tweaks

* Move labeler fetch below auth

* Mitigation: hardcode the bluesky moderation labeler name

* Bump sdk

* Add missing translated string

Co-authored-by: Takayuki KUSANO <65759+tkusano@users.noreply.github.com>

* Add missing translated string

Co-authored-by: Takayuki KUSANO <65759+tkusano@users.noreply.github.com>

* Hailey's fix for incorrect profile tabs

Co-authored-by: Hailey <me@haileyok.com>

* Feedback

* Fix borders, add bottom space

* Hailey's fix pt 2

Co-authored-by: Hailey <me@haileyok.com>

* Fix post tabs

* Integrate feedback pt 1

Co-authored-by: Takayuki KUSANO <65759+tkusano@users.noreply.github.com>

* Integrate feedback pt 2

Co-authored-by: Takayuki KUSANO <65759+tkusano@users.noreply.github.com>

* Integrate feedback pt 3

Co-authored-by: Takayuki KUSANO <65759+tkusano@users.noreply.github.com>

* Integrate feedback pt 4

Co-authored-by: Takayuki KUSANO <65759+tkusano@users.noreply.github.com>

* Integrate feedback pt 5

Co-authored-by: Takayuki KUSANO <65759+tkusano@users.noreply.github.com>

* Integrate feedback pt 6

Co-authored-by: Takayuki KUSANO <65759+tkusano@users.noreply.github.com>

* Integrate feedback pt 7

Co-authored-by: Takayuki KUSANO <65759+tkusano@users.noreply.github.com>

* Integrate feedback pt 8

Co-authored-by: Takayuki KUSANO <65759+tkusano@users.noreply.github.com>

* Format

* Integrate new bday modal

* Use public agent for getServices

* Update casing

---------

Co-authored-by: Eric Bailey <git@esb.lol>
Co-authored-by: Takayuki KUSANO <65759+tkusano@users.noreply.github.com>
Co-authored-by: Hailey <me@haileyok.com>
This commit is contained in:
Paul Frazee 2024-03-18 12:46:28 -07:00 committed by GitHub
parent d5ebbeb3fc
commit 20d463ff2f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
165 changed files with 7034 additions and 5009 deletions

View file

@ -47,6 +47,7 @@ interface Props extends ComponentProps<typeof TouchableOpacity> {
anchorNoUnderline?: boolean
navigationAction?: 'push' | 'replace' | 'navigate'
onPointerEnter?: () => void
onBeforePress?: () => void
}
export const Link = memo(function Link({
@ -60,6 +61,7 @@ export const Link = memo(function Link({
accessible,
anchorNoUnderline,
navigationAction,
onBeforePress,
...props
}: Props) {
const t = useTheme()
@ -70,6 +72,7 @@ export const Link = memo(function Link({
const onPress = React.useCallback(
(e?: Event) => {
onBeforePress?.()
if (typeof href === 'string') {
return onPressInner(
closeModal,
@ -81,7 +84,7 @@ export const Link = memo(function Link({
)
}
},
[closeModal, navigation, navigationAction, href, openLink],
[closeModal, navigation, navigationAction, href, openLink, onBeforePress],
)
if (noFeedback) {
@ -262,6 +265,7 @@ interface TextLinkOnWebOnlyProps extends TextProps {
accessibilityHint?: string
title?: string
navigationAction?: 'push' | 'replace' | 'navigate'
disableMismatchWarning?: boolean
onPointerEnter?: () => void
}
export const TextLinkOnWebOnly = memo(function DesktopWebTextLink({
@ -273,6 +277,7 @@ export const TextLinkOnWebOnly = memo(function DesktopWebTextLink({
numberOfLines,
lineHeight,
navigationAction,
disableMismatchWarning,
...props
}: TextLinkOnWebOnlyProps) {
if (isWeb) {
@ -287,6 +292,7 @@ export const TextLinkOnWebOnly = memo(function DesktopWebTextLink({
lineHeight={lineHeight}
title={props.title}
navigationAction={navigationAction}
disableMismatchWarning={disableMismatchWarning}
{...props}
/>
)

View file

@ -11,7 +11,7 @@ import {sanitizeHandle} from 'lib/strings/handles'
import {isAndroid, isWeb} from 'platform/detection'
import {TimeElapsed} from './TimeElapsed'
import {makeProfileLink} from 'lib/routes/links'
import {ModerationUI} from '@atproto/api'
import {ModerationDecision, ModerationUI} from '@atproto/api'
import {usePrefetchProfileQuery} from '#/state/queries/profile'
interface PostMetaOpts {
@ -21,6 +21,7 @@ interface PostMetaOpts {
handle: string
displayName?: string | undefined
}
moderation: ModerationDecision | undefined
authorHasWarning: boolean
postHref: string
timestamp: string
@ -55,9 +56,14 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => {
style={[pal.text, opts.displayNameStyle]}
numberOfLines={1}
lineHeight={1.2}
disableMismatchWarning
text={
<>
{sanitizeDisplayName(displayName)}&nbsp;
{sanitizeDisplayName(
displayName,
opts.moderation?.ui('displayName'),
)}
&nbsp;
<Text
type="md"
numberOfLines={1}

View file

@ -24,9 +24,9 @@ import {
} from '#/components/icons/Camera'
import {StreamingLive_Stroke2_Corner0_Rounded as Library} from '#/components/icons/StreamingLive'
import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
import {useTheme} from '#/alf'
import {useTheme, tokens} from '#/alf'
export type UserAvatarType = 'user' | 'algo' | 'list'
export type UserAvatarType = 'user' | 'algo' | 'list' | 'labeler'
interface BaseUserAvatarProps {
type?: UserAvatarType
@ -101,6 +101,29 @@ let DefaultAvatar = ({
</Svg>
)
}
if (type === 'labeler') {
return (
<Svg
testID="userAvatarFallback"
width={size}
height={size}
viewBox="0 0 32 32"
fill="none"
stroke="none">
<Path
d="M28 0H4C1.79086 0 0 1.79086 0 4V28C0 30.2091 1.79086 32 4 32H28C30.2091 32 32 30.2091 32 28V4C32 1.79086 30.2091 0 28 0Z"
fill={tokens.color.temp_purple}
/>
<Path
d="M24 9.75L16 7L8 9.75V15.9123C8 20.8848 12 23 16 25.1579C20 23 24 20.8848 24 15.9123V9.75Z"
stroke="white"
strokeWidth="2"
strokeLinecap="square"
strokeLinejoin="round"
/>
</Svg>
)
}
return (
<Svg
testID="userAvatarFallback"
@ -134,7 +157,7 @@ let UserAvatar = ({
const backgroundColor = pal.colors.backgroundLight
const aviStyle = useMemo(() => {
if (type === 'algo' || type === 'list') {
if (type === 'algo' || type === 'list' || type === 'labeler') {
return {
width: size,
height: size,

View file

@ -7,7 +7,7 @@ import {msg, Trans} from '@lingui/macro'
import {colors} from 'lib/styles'
import {useTheme} from 'lib/ThemeContext'
import {useTheme as useAlfTheme} from '#/alf'
import {useTheme as useAlfTheme, tokens} from '#/alf'
import {openCamera, openCropper, openPicker} from '../../../lib/media/picker'
import {
usePhotoLibraryPermission,
@ -26,10 +26,12 @@ import {StreamingLive_Stroke2_Corner0_Rounded as Library} from '#/components/ico
import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
export function UserBanner({
type,
banner,
moderation,
onSelectNewBanner,
}: {
type?: 'labeler' | 'default'
banner?: string | null
moderation?: ModerationUI
onSelectNewBanner?: (img: RNImage | null) => void
@ -167,7 +169,10 @@ export function UserBanner({
) : (
<View
testID="userBannerFallback"
style={[styles.bannerImage, styles.defaultBanner]}
style={[
styles.bannerImage,
type === 'labeler' ? styles.labelerBanner : styles.defaultBanner,
]}
/>
)
}
@ -191,4 +196,7 @@ const styles = StyleSheet.create({
defaultBanner: {
backgroundColor: '#0070ff',
},
labelerBanner: {
backgroundColor: tokens.color.temp_purple,
},
})

View file

@ -16,7 +16,6 @@ import * as Toast from '../Toast'
import {EventStopper} from '../EventStopper'
import {useDialogControl} from '#/components/Dialog'
import * as Prompt from '#/components/Prompt'
import {useModalControls} from '#/state/modals'
import {makeProfileLink} from '#/lib/routes/links'
import {CommonNavigatorParams} from '#/lib/routes/types'
import {getCurrentRoute} from 'lib/routes/helpers'
@ -33,6 +32,7 @@ import {useSession} from '#/state/session'
import {isWeb} from '#/platform/detection'
import {richTextToString} from '#/lib/strings/rich-text-helpers'
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
import {ReportDialog, useReportDialogControl} from '#/components/ReportDialog'
import {atoms as a, useTheme as useAlf} from '#/alf'
import * as Menu from '#/components/Menu'
@ -45,7 +45,6 @@ import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/
import {BubbleQuestion_Stroke2_Corner0_Rounded as Translate} from '#/components/icons/Bubble'
import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning'
import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
let PostDropdownBtn = ({
testID,
@ -55,7 +54,6 @@ let PostDropdownBtn = ({
record,
richText,
style,
showAppealLabelItem,
hitSlop,
}: {
testID: string
@ -65,7 +63,6 @@ let PostDropdownBtn = ({
record: AppBskyFeedPost.Record
richText: RichTextAPI
style?: StyleProp<ViewStyle>
showAppealLabelItem?: boolean
hitSlop?: PressableProps['hitSlop']
}): React.ReactNode => {
const {hasSession, currentAccount} = useSession()
@ -73,7 +70,6 @@ let PostDropdownBtn = ({
const alf = useAlf()
const {_} = useLingui()
const defaultCtrlColor = theme.palette.default.postCtrl
const {openModal} = useModalControls()
const langPrefs = useLanguagePrefs()
const mutedThreads = useMutedThreads()
const toggleThreadMute = useToggleThreadMute()
@ -83,6 +79,7 @@ let PostDropdownBtn = ({
const openLink = useOpenLink()
const navigation = useNavigation()
const {mutedWordsDialogControl} = useGlobalDialogsControlContext()
const reportDialogControl = useReportDialogControl()
const deletePromptControl = useDialogControl()
const hidePromptControl = useDialogControl()
const loggedOutWarningPromptControl = useDialogControl()
@ -293,13 +290,7 @@ let PostDropdownBtn = ({
<Menu.Item
testID="postDropdownReportBtn"
label={_(msg`Report post`)}
onPress={() => {
openModal({
name: 'report',
uri: postUri,
cid: postCid,
})
}}>
onPress={() => reportDialogControl.open()}>
<Menu.ItemText>{_(msg`Report post`)}</Menu.ItemText>
<Menu.ItemIcon icon={Warning} position="right" />
</Menu.Item>
@ -314,28 +305,6 @@ let PostDropdownBtn = ({
<Menu.ItemIcon icon={Trash} position="right" />
</Menu.Item>
)}
{showAppealLabelItem && (
<>
<Menu.Divider />
<Menu.Item
testID="postDropdownAppealBtn"
label={_(msg`Appeal content warning`)}
onPress={() => {
openModal({
name: 'appeal-label',
uri: postUri,
cid: postCid,
})
}}>
<Menu.ItemText>
{_(msg`Appeal content warning`)}
</Menu.ItemText>
<Menu.ItemIcon icon={CircleInfo} position="right" />
</Menu.Item>
</>
)}
</Menu.Group>
</Menu.Outer>
</Menu.Root>
@ -359,6 +328,15 @@ let PostDropdownBtn = ({
confirmButtonCta={_(msg`Hide`)}
/>
<ReportDialog
control={reportDialogControl}
params={{
type: 'post',
uri: postUri,
cid: postCid,
}}
/>
<Prompt.Basic
control={loggedOutWarningPromptControl}
title={_(msg`Note about sharing`)}

View file

@ -1,145 +0,0 @@
import React from 'react'
import {Pressable, StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {usePalette} from 'lib/hooks/usePalette'
import {ModerationUI, PostModeration} from '@atproto/api'
import {Text} from '../text/Text'
import {ShieldExclamation} from 'lib/icons'
import {describeModerationCause} from 'lib/moderation'
import {useLingui} from '@lingui/react'
import {msg, Trans} from '@lingui/macro'
import {useModalControls} from '#/state/modals'
import {isPostMediaBlurred} from 'lib/moderation'
export function ContentHider({
testID,
moderation,
moderationDecisions,
ignoreMute,
ignoreQuoteDecisions,
style,
childContainerStyle,
children,
}: React.PropsWithChildren<{
testID?: string
moderation: ModerationUI
moderationDecisions?: PostModeration['decisions']
ignoreMute?: boolean
ignoreQuoteDecisions?: boolean
style?: StyleProp<ViewStyle>
childContainerStyle?: StyleProp<ViewStyle>
}>) {
const pal = usePalette('default')
const {_} = useLingui()
const [override, setOverride] = React.useState(false)
const {openModal} = useModalControls()
if (
!moderation.blur ||
(ignoreMute && moderation.cause?.type === 'muted') ||
shouldIgnoreQuote(moderationDecisions, ignoreQuoteDecisions)
) {
return (
<View testID={testID} style={[styles.outer, style]}>
{children}
</View>
)
}
const isMute = ['muted', 'muted-word'].includes(moderation.cause?.type || '')
const desc = describeModerationCause(moderation.cause, 'content')
return (
<View testID={testID} style={[styles.outer, style]}>
<Pressable
onPress={() => {
if (!moderation.noOverride) {
setOverride(v => !v)
} else {
openModal({
name: 'moderation-details',
context: 'content',
moderation,
})
}
}}
accessibilityRole="button"
accessibilityHint={
override ? _(msg`Hide the content`) : _(msg`Show the content`)
}
accessibilityLabel=""
style={[
styles.cover,
moderation.noOverride
? {borderWidth: 1, borderColor: pal.colors.borderDark}
: pal.viewLight,
]}>
<Pressable
onPress={() => {
openModal({
name: 'moderation-details',
context: 'content',
moderation,
})
}}
accessibilityRole="button"
accessibilityLabel={_(msg`Learn more about this warning`)}
accessibilityHint="">
{isMute ? (
<FontAwesomeIcon
icon={['far', 'eye-slash']}
size={18}
color={pal.colors.textLight}
/>
) : (
<ShieldExclamation size={18} style={pal.textLight} />
)}
</Pressable>
<Text type="md" style={[pal.text, {flex: 1}]} numberOfLines={2}>
{desc.name}
</Text>
<View style={styles.showBtn}>
<Text type="lg" style={pal.link}>
{moderation.noOverride ? (
<Trans>Learn more</Trans>
) : override ? (
<Trans>Hide</Trans>
) : (
<Trans>Show</Trans>
)}
</Text>
</View>
</Pressable>
{override && <View style={childContainerStyle}>{children}</View>}
</View>
)
}
function shouldIgnoreQuote(
decisions: PostModeration['decisions'] | undefined,
ignore: boolean | undefined,
): boolean {
if (!decisions || !ignore) {
return false
}
return !isPostMediaBlurred(decisions)
}
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',
},
})

View file

@ -1,61 +0,0 @@
import React from 'react'
import {Pressable, StyleProp, View, ViewStyle} from 'react-native'
import {ComAtprotoLabelDefs} from '@atproto/api'
import {Text} from '../text/Text'
import {usePalette} from 'lib/hooks/usePalette'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useModalControls} from '#/state/modals'
export function LabelInfo({
details,
labels,
style,
}: {
details: {did: string} | {uri: string; cid: string}
labels: ComAtprotoLabelDefs.Label[] | undefined
style?: StyleProp<ViewStyle>
}) {
const pal = usePalette('default')
const {_} = useLingui()
const {openModal} = useModalControls()
if (!labels) {
return null
}
labels = labels.filter(l => !l.val.startsWith('!'))
if (!labels.length) {
return null
}
return (
<View
style={[
pal.viewLight,
{
flexDirection: 'row',
flexWrap: 'wrap',
paddingHorizontal: 12,
paddingVertical: 10,
borderRadius: 8,
},
style,
]}>
<Text type="sm" style={pal.text}>
<Trans>
A content warning has been applied to this{' '}
{'did' in details ? 'account' : 'post'}.
</Trans>{' '}
</Text>
<Pressable
accessibilityRole="button"
accessibilityLabel={_(msg`Appeal this decision`)}
accessibilityHint=""
onPress={() => openModal({name: 'appeal-label', ...details})}>
<Text type="sm" style={pal.link}>
<Trans>Appeal this decision.</Trans>
</Text>
</Pressable>
</View>
)
}

View file

@ -1,67 +0,0 @@
import React from 'react'
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 {ShieldExclamation} from 'lib/icons'
import {describeModerationCause} from 'lib/moderation'
import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useModalControls} from '#/state/modals'
export function PostAlerts({
moderation,
style,
}: {
moderation: ModerationUI
includeMute?: boolean
style?: StyleProp<ViewStyle>
}) {
const pal = usePalette('default')
const {_} = useLingui()
const {openModal} = useModalControls()
const shouldAlert = !!moderation.cause && moderation.alert
if (!shouldAlert) {
return null
}
const desc = describeModerationCause(moderation.cause, 'content')
return (
<Pressable
onPress={() => {
openModal({
name: 'moderation-details',
context: 'content',
moderation,
})
}}
accessibilityRole="button"
accessibilityLabel={_(msg`Learn more about this warning`)}
accessibilityHint=""
style={[styles.container, pal.viewLight, style]}>
<ShieldExclamation style={pal.text} size={16} />
<Text type="lg" style={[pal.text]}>
{desc.name}{' '}
<Text type="lg" style={[pal.link, styles.learnMoreBtn]}>
<Trans>Learn More</Trans>
</Text>
</Text>
</Pressable>
)
}
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
paddingVertical: 8,
paddingLeft: 14,
paddingHorizontal: 16,
borderRadius: 8,
},
learnMoreBtn: {
marginLeft: 'auto',
},
})

View file

@ -1,142 +0,0 @@
import React, {ComponentProps} from 'react'
import {StyleSheet, Pressable, View, ViewStyle, StyleProp} from 'react-native'
import {ModerationUI} from '@atproto/api'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {usePalette} from 'lib/hooks/usePalette'
import {Link} from '../Link'
import {Text} from '../text/Text'
import {addStyle} from 'lib/styles'
import {describeModerationCause} from 'lib/moderation'
import {ShieldExclamation} from 'lib/icons'
import {useLingui} from '@lingui/react'
import {Trans, msg} from '@lingui/macro'
import {useModalControls} from '#/state/modals'
interface Props extends ComponentProps<typeof Link> {
iconSize: number
iconStyles: StyleProp<ViewStyle>
moderation: ModerationUI
}
export function PostHider({
testID,
href,
moderation,
style,
children,
iconSize,
iconStyles,
...props
}: Props) {
const pal = usePalette('default')
const {_} = useLingui()
const [override, setOverride] = React.useState(false)
const {openModal} = useModalControls()
if (!moderation.blur) {
return (
<Link
testID={testID}
style={style}
href={href}
noFeedback
accessible={false}
{...props}>
{children}
</Link>
)
}
const isMute = ['muted', 'muted-word'].includes(moderation.cause?.type || '')
const desc = describeModerationCause(moderation.cause, 'content')
return !override ? (
<Pressable
onPress={() => {
if (!moderation.noOverride) {
setOverride(v => !v)
}
}}
accessibilityRole="button"
accessibilityHint={
override ? _(msg`Hide the content`) : _(msg`Show the content`)
}
accessibilityLabel=""
style={[
styles.description,
override ? {paddingBottom: 0} : undefined,
pal.view,
]}>
<Pressable
onPress={() => {
openModal({
name: 'moderation-details',
context: 'content',
moderation,
})
}}
accessibilityRole="button"
accessibilityLabel={_(msg`Learn more about this warning`)}
accessibilityHint="">
<View
style={[
pal.viewLight,
{
width: iconSize,
height: iconSize,
borderRadius: iconSize,
alignItems: 'center',
justifyContent: 'center',
},
iconStyles,
]}>
{isMute ? (
<FontAwesomeIcon
icon={['far', 'eye-slash']}
size={14}
color={pal.colors.textLight}
/>
) : (
<ShieldExclamation size={14} style={pal.textLight} />
)}
</View>
</Pressable>
<Text type="sm" style={[{flex: 1}, pal.textLight]} numberOfLines={1}>
{desc.name}
</Text>
{!moderation.noOverride && (
<Text type="sm" style={[styles.showBtn, pal.link]}>
{override ? <Trans>Hide</Trans> : <Trans>Show</Trans>}
</Text>
)}
</Pressable>
) : (
<Link
testID={testID}
style={addStyle(style, styles.child)}
href={href}
noFeedback>
{children}
</Link>
)
}
const styles = StyleSheet.create({
description: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
paddingVertical: 10,
paddingLeft: 6,
paddingRight: 18,
marginTop: 1,
},
showBtn: {
marginLeft: 'auto',
alignSelf: 'center',
},
child: {
borderWidth: 0,
borderTopWidth: 0,
borderRadius: 8,
},
})

View file

@ -1,89 +0,0 @@
import React from 'react'
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 {ShieldExclamation} from 'lib/icons'
import {
describeModerationCause,
getProfileModerationCauses,
} from 'lib/moderation'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {useModalControls} from '#/state/modals'
export function ProfileHeaderAlerts({
moderation,
style,
}: {
moderation: ProfileModeration
style?: StyleProp<ViewStyle>
}) {
const pal = usePalette('default')
const {_} = useLingui()
const {openModal} = useModalControls()
const causes = getProfileModerationCauses(moderation)
if (!causes.length) {
return null
}
return (
<View style={styles.grid}>
{causes.map(cause => {
const isMute = cause.type === 'muted'
const desc = describeModerationCause(cause, 'account')
return (
<Pressable
testID="profileHeaderAlert"
key={desc.name}
onPress={() => {
openModal({
name: 'moderation-details',
context: 'content',
moderation: {cause},
})
}}
accessibilityRole="button"
accessibilityLabel={_(msg`Learn more about this warning`)}
accessibilityHint=""
style={[styles.container, pal.viewLight, style]}>
{isMute ? (
<FontAwesomeIcon
icon={['far', 'eye-slash']}
size={14}
color={pal.colors.textLight}
/>
) : (
<ShieldExclamation style={pal.text} size={18} />
)}
<Text type="sm" style={[{flex: 1}, pal.text]}>
{desc.name}
</Text>
<Text type="sm" style={[pal.link, styles.learnMoreBtn]}>
<Trans>Learn More</Trans>
</Text>
</Pressable>
)
})}
</View>
)
}
const styles = StyleSheet.create({
grid: {
gap: 4,
},
container: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
paddingVertical: 12,
paddingHorizontal: 16,
borderRadius: 8,
},
learnMoreBtn: {
marginLeft: 'auto',
},
})

View file

@ -1,180 +0,0 @@
import React from 'react'
import {
TouchableWithoutFeedback,
StyleProp,
StyleSheet,
View,
ViewStyle,
} from 'react-native'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {useNavigation} from '@react-navigation/native'
import {ModerationUI} from '@atproto/api'
import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {NavigationProp} from 'lib/routes/types'
import {Text} from '../text/Text'
import {Button} from '../forms/Button'
import {describeModerationCause} from 'lib/moderation'
import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useModalControls} from '#/state/modals'
import {s} from '#/lib/styles'
import {CenteredView} from '../Views'
export function ScreenHider({
testID,
screenDescription,
moderation,
style,
containerStyle,
children,
}: React.PropsWithChildren<{
testID?: string
screenDescription: string
moderation: ModerationUI
style?: StyleProp<ViewStyle>
containerStyle?: StyleProp<ViewStyle>
}>) {
const pal = usePalette('default')
const palInverted = usePalette('inverted')
const {_} = useLingui()
const [override, setOverride] = React.useState(false)
const navigation = useNavigation<NavigationProp>()
const {isMobile} = useWebMediaQueries()
const {openModal} = useModalControls()
if (!moderation.blur || override) {
return (
<View testID={testID} style={style}>
{children}
</View>
)
}
const isNoPwi =
moderation.cause?.type === 'label' &&
moderation.cause?.labelDef.id === '!no-unauthenticated'
const desc = describeModerationCause(moderation.cause, 'account')
return (
<CenteredView
style={[styles.container, pal.view, containerStyle]}
sideBorders>
<View style={styles.iconContainer}>
<View style={[styles.icon, palInverted.view]}>
<FontAwesomeIcon
icon={isNoPwi ? ['far', 'eye-slash'] : 'exclamation'}
style={pal.textInverted as FontAwesomeIconStyle}
size={24}
/>
</View>
</View>
<Text type="title-2xl" style={[styles.title, pal.text]}>
{isNoPwi ? (
<Trans>Sign-in Required</Trans>
) : (
<Trans>Content Warning</Trans>
)}
</Text>
<Text type="2xl" style={[styles.description, pal.textLight]}>
{isNoPwi ? (
<Trans>
This account has requested that users sign in to view their profile.
</Trans>
) : (
<>
<Trans>This {screenDescription} has been flagged:</Trans>
<Text type="2xl-medium" style={[pal.text, s.ml5]}>
{desc.name}.
</Text>
<TouchableWithoutFeedback
onPress={() => {
openModal({
name: 'moderation-details',
context: 'account',
moderation,
})
}}
accessibilityRole="button"
accessibilityLabel={_(msg`Learn more about this warning`)}
accessibilityHint="">
<Text type="2xl" style={pal.link}>
<Trans>Learn More</Trans>
</Text>
</TouchableWithoutFeedback>
</>
)}{' '}
</Text>
{isMobile && <View style={styles.spacer} />}
<View style={styles.btnContainer}>
<Button
type="inverted"
onPress={() => {
if (navigation.canGoBack()) {
navigation.goBack()
} else {
navigation.navigate('Home')
}
}}
style={styles.btn}>
<Text type="button-lg" style={pal.textInverted}>
<Trans>Go back</Trans>
</Text>
</Button>
{!moderation.noOverride && (
<Button
type="default"
onPress={() => setOverride(v => !v)}
style={styles.btn}>
<Text type="button-lg" style={pal.text}>
<Trans>Show anyway</Trans>
</Text>
</Button>
)}
</View>
</CenteredView>
)
}
const styles = StyleSheet.create({
spacer: {
flex: 1,
},
container: {
flex: 1,
paddingTop: 100,
paddingBottom: 150,
},
iconContainer: {
alignItems: 'center',
marginBottom: 10,
},
icon: {
borderRadius: 25,
width: 50,
height: 50,
alignItems: 'center',
justifyContent: 'center',
},
title: {
textAlign: 'center',
marginBottom: 10,
},
description: {
marginBottom: 10,
paddingHorizontal: 20,
textAlign: 'center',
},
btnContainer: {
flexDirection: 'row',
justifyContent: 'center',
marginVertical: 10,
gap: 10,
},
btn: {
paddingHorizontal: 20,
paddingVertical: 14,
},
})

View file

@ -41,7 +41,6 @@ let PostCtrls = ({
post,
record,
richText,
showAppealLabelItem,
style,
onPressReply,
logContext,
@ -50,7 +49,6 @@ let PostCtrls = ({
post: Shadow<AppBskyFeedDefs.PostView>
record: AppBskyFeedPost.Record
richText: RichTextAPI
showAppealLabelItem?: boolean
style?: StyleProp<ViewStyle>
onPressReply: () => void
logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
@ -232,7 +230,6 @@ let PostCtrls = ({
postUri={post.uri}
record={record}
richText={richText}
showAppealLabelItem={showAppealLabelItem}
style={styles.btnPad}
hitSlop={big ? HITSLOP_20 : HITSLOP_10}
/>

View file

@ -1,13 +1,15 @@
import React from 'react'
import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
import {
AppBskyFeedDefs,
AppBskyEmbedRecord,
AppBskyFeedPost,
AppBskyEmbedImages,
AppBskyEmbedRecordWithMedia,
ModerationUI,
AppBskyEmbedExternal,
RichText as RichTextAPI,
moderatePost,
ModerationDecision,
} from '@atproto/api'
import {AtUri} from '@atproto/api'
import {PostMeta} from '../PostMeta'
@ -16,20 +18,20 @@ import {Text} from '../text/Text'
import {usePalette} from 'lib/hooks/usePalette'
import {ComposerOptsQuote} from 'state/shell/composer'
import {PostEmbeds} from '.'
import {PostAlerts} from '../moderation/PostAlerts'
import {PostAlerts} from '../../../../components/moderation/PostAlerts'
import {makeProfileLink} from 'lib/routes/links'
import {InfoCircleIcon} from 'lib/icons'
import {Trans} from '@lingui/macro'
import {useModerationOpts} from '#/state/queries/preferences'
import {ContentHider} from '../../../../components/moderation/ContentHider'
import {RichText} from '#/components/RichText'
import {atoms as a} from '#/alf'
export function MaybeQuoteEmbed({
embed,
moderation,
style,
}: {
embed: AppBskyEmbedRecord.View
moderation: ModerationUI
style?: StyleProp<ViewStyle>
}) {
const pal = usePalette('default')
@ -39,17 +41,9 @@ export function MaybeQuoteEmbed({
AppBskyFeedPost.validateRecord(embed.record.value).success
) {
return (
<QuoteEmbed
quote={{
author: embed.record.author,
cid: embed.record.cid,
uri: embed.record.uri,
indexedAt: embed.record.indexedAt,
text: embed.record.value.text,
facets: embed.record.value.facets,
embeds: embed.record.embeds,
}}
moderation={moderation}
<QuoteEmbedModerated
viewRecord={embed.record}
postRecord={embed.record.value}
style={style}
/>
)
@ -75,19 +69,49 @@ export function MaybeQuoteEmbed({
return null
}
function QuoteEmbedModerated({
viewRecord,
postRecord,
style,
}: {
viewRecord: AppBskyEmbedRecord.ViewRecord
postRecord: AppBskyFeedPost.Record
style?: StyleProp<ViewStyle>
}) {
const moderationOpts = useModerationOpts()
const moderation = React.useMemo(() => {
return moderationOpts
? moderatePost(viewRecordToPostView(viewRecord), moderationOpts)
: undefined
}, [viewRecord, moderationOpts])
const quote = {
author: viewRecord.author,
cid: viewRecord.cid,
uri: viewRecord.uri,
indexedAt: viewRecord.indexedAt,
text: postRecord.text,
facets: postRecord.facets,
embeds: viewRecord.embeds,
}
return <QuoteEmbed quote={quote} moderation={moderation} style={style} />
}
export function QuoteEmbed({
quote,
moderation,
style,
}: {
quote: ComposerOptsQuote
moderation?: ModerationUI
moderation?: ModerationDecision
style?: StyleProp<ViewStyle>
}) {
const pal = usePalette('default')
const itemUrip = new AtUri(quote.uri)
const itemHref = makeProfileLink(quote.author, 'post', itemUrip.rkey)
const itemTitle = `Post by ${quote.author.handle}`
const richText = React.useMemo(
() =>
quote.text.trim()
@ -95,6 +119,7 @@ export function QuoteEmbed({
: undefined,
[quote.text, quote.facets],
)
const embed = React.useMemo(() => {
const e = quote.embeds?.[0]
@ -108,40 +133,52 @@ export function QuoteEmbed({
return e.media
}
}, [quote.embeds])
return (
<Link
style={[styles.container, pal.borderDark, style]}
hoverStyle={{borderColor: pal.colors.borderLinkHover}}
href={itemHref}
title={itemTitle}>
<View pointerEvents="none">
<PostMeta
author={quote.author}
showAvatar
authorHasWarning={false}
postHref={itemHref}
timestamp={quote.indexedAt}
/>
</View>
{moderation ? (
<PostAlerts moderation={moderation} style={styles.alert} />
) : null}
{richText ? (
<RichText
enableTags
value={richText}
style={[a.text_md]}
numberOfLines={20}
disableLinks
authorHandle={quote.author.handle}
/>
) : null}
{embed && <PostEmbeds embed={embed} moderation={{}} />}
</Link>
<ContentHider modui={moderation?.ui('contentList')}>
<Link
style={[styles.container, pal.borderDark, style]}
hoverStyle={{borderColor: pal.colors.borderLinkHover}}
href={itemHref}
title={itemTitle}>
<View pointerEvents="none">
<PostMeta
author={quote.author}
moderation={moderation}
showAvatar
authorHasWarning={false}
postHref={itemHref}
timestamp={quote.indexedAt}
/>
</View>
{moderation ? (
<PostAlerts modui={moderation.ui('contentView')} style={[a.py_xs]} />
) : null}
{richText ? (
<RichText
value={richText}
style={[a.text_md]}
numberOfLines={20}
disableLinks
/>
) : null}
{embed && <PostEmbeds embed={embed} moderation={moderation} />}
</Link>
</ContentHider>
)
}
export default QuoteEmbed
function viewRecordToPostView(
viewRecord: AppBskyEmbedRecord.ViewRecord,
): AppBskyFeedDefs.PostView {
const {value, embeds, ...rest} = viewRecord
return {
...rest,
$type: 'app.bsky.feed.defs#postView',
record: value,
embed: embeds?.[0],
}
}
const styles = StyleSheet.create({
container: {

View file

@ -15,8 +15,7 @@ import {
AppBskyEmbedRecordWithMedia,
AppBskyFeedDefs,
AppBskyGraphDefs,
ModerationUI,
PostModeration,
ModerationDecision,
} from '@atproto/api'
import {Link} from '../Link'
import {ImageLayoutGrid} from '../images/ImageLayoutGrid'
@ -26,9 +25,8 @@ import {ExternalLinkEmbed} from './ExternalLinkEmbed'
import {MaybeQuoteEmbed} from './QuoteEmbed'
import {AutoSizedImage} from '../images/AutoSizedImage'
import {ListEmbed} from './ListEmbed'
import {isCauseALabelOnUri, isQuoteBlurred} from 'lib/moderation'
import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard'
import {ContentHider} from '../moderation/ContentHider'
import {ContentHider} from '../../../../components/moderation/ContentHider'
import {isNative} from '#/platform/detection'
import {shareUrl} from '#/lib/sharing'
@ -42,12 +40,10 @@ type Embed =
export function PostEmbeds({
embed,
moderation,
moderationDecisions,
style,
}: {
embed?: Embed
moderation: ModerationUI
moderationDecisions?: PostModeration['decisions']
moderation?: ModerationDecision
style?: StyleProp<ViewStyle>
}) {
const pal = usePalette('default')
@ -66,18 +62,10 @@ export function PostEmbeds({
// quote post with media
// =
if (AppBskyEmbedRecordWithMedia.isView(embed)) {
const isModOnQuote =
(AppBskyEmbedRecord.isViewRecord(embed.record.record) &&
isCauseALabelOnUri(moderation.cause, embed.record.record.uri)) ||
(moderationDecisions && isQuoteBlurred(moderationDecisions))
const mediaModeration = isModOnQuote ? {} : moderation
const quoteModeration = isModOnQuote ? moderation : {}
return (
<View style={style}>
<PostEmbeds embed={embed.media} moderation={mediaModeration} />
<ContentHider moderation={quoteModeration}>
<MaybeQuoteEmbed embed={embed.record} moderation={quoteModeration} />
</ContentHider>
<PostEmbeds embed={embed.media} moderation={moderation} />
<MaybeQuoteEmbed embed={embed.record} />
</View>
)
}
@ -86,6 +74,7 @@ export function PostEmbeds({
// custom feed embed (i.e. generator view)
// =
if (AppBskyFeedDefs.isGeneratorView(embed.record)) {
// TODO moderation
return (
<FeedSourceCard
feedUri={embed.record.uri}
@ -97,16 +86,13 @@ export function PostEmbeds({
// list embed
if (AppBskyGraphDefs.isListView(embed.record)) {
// TODO moderation
return <ListEmbed item={embed.record} />
}
// quote post
// =
return (
<ContentHider moderation={moderation}>
<MaybeQuoteEmbed embed={embed} style={style} moderation={moderation} />
</ContentHider>
)
return <MaybeQuoteEmbed embed={embed} style={style} />
}
// image embed
@ -132,35 +118,41 @@ export function PostEmbeds({
if (images.length === 1) {
const {alt, thumb, aspectRatio} = images[0]
return (
<View style={[styles.imagesContainer, style]}>
<AutoSizedImage
alt={alt}
uri={thumb}
dimensionsHint={aspectRatio}
onPress={() => _openLightbox(0)}
onPressIn={() => onPressIn(0)}
style={[styles.singleImage]}>
{alt === '' ? null : (
<View style={styles.altContainer}>
<Text style={styles.alt} accessible={false}>
ALT
</Text>
</View>
)}
</AutoSizedImage>
</View>
<ContentHider modui={moderation?.ui('contentMedia')}>
<View style={[styles.imagesContainer, style]}>
<AutoSizedImage
alt={alt}
uri={thumb}
dimensionsHint={aspectRatio}
onPress={() => _openLightbox(0)}
onPressIn={() => onPressIn(0)}
style={[styles.singleImage]}>
{alt === '' ? null : (
<View style={styles.altContainer}>
<Text style={styles.alt} accessible={false}>
ALT
</Text>
</View>
)}
</AutoSizedImage>
</View>
</ContentHider>
)
}
return (
<View style={[styles.imagesContainer, style]}>
<ImageLayoutGrid
images={embed.images}
onPress={_openLightbox}
onPressIn={onPressIn}
style={embed.images.length === 1 ? [styles.singleImage] : undefined}
/>
</View>
<ContentHider modui={moderation?.ui('contentMedia')}>
<View style={[styles.imagesContainer, style]}>
<ImageLayoutGrid
images={embed.images}
onPress={_openLightbox}
onPressIn={onPressIn}
style={
embed.images.length === 1 ? [styles.singleImage] : undefined
}
/>
</View>
</ContentHider>
)
}
}
@ -171,15 +163,17 @@ export function PostEmbeds({
const link = embed.external
return (
<Link
asAnchor
anchorNoUnderline
href={link.uri}
style={[styles.extOuter, pal.view, pal.borderDark, style]}
hoverStyle={{borderColor: pal.colors.borderLinkHover}}
onLongPress={onShareExternal}>
<ExternalLinkEmbed link={link} />
</Link>
<ContentHider modui={moderation?.ui('contentMedia')}>
<Link
asAnchor
anchorNoUnderline
href={link.uri}
style={[styles.extOuter, pal.view, pal.borderDark, style]}
hoverStyle={{borderColor: pal.colors.borderLinkHover}}
onLongPress={onShareExternal}>
<ExternalLinkEmbed link={link} />
</Link>
</ContentHider>
)
}