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

@ -15,6 +15,7 @@ import LinearGradient from 'react-native-linear-gradient'
import {useTheme, atoms as a, tokens, android, flatten} from '#/alf'
import {Props as SVGIconProps} from '#/components/icons/common'
import {normalizeTextStyles} from '#/components/Typography'
export type ButtonVariant = 'solid' | 'outline' | 'ghost' | 'gradient'
export type ButtonColor =
@ -139,7 +140,7 @@ export function Button({
}))
}, [setState])
const {baseStyles, hoverStyles, focusStyles} = React.useMemo(() => {
const {baseStyles, hoverStyles} = React.useMemo(() => {
const baseStyles: ViewStyle[] = []
const hoverStyles: ViewStyle[] = []
const light = t.name === 'light'
@ -191,14 +192,14 @@ export function Button({
if (variant === 'solid') {
if (!disabled) {
baseStyles.push({
backgroundColor: t.palette.contrast_50,
backgroundColor: t.palette.contrast_25,
})
hoverStyles.push({
backgroundColor: t.palette.contrast_100,
backgroundColor: t.palette.contrast_50,
})
} else {
baseStyles.push({
backgroundColor: t.palette.contrast_200,
backgroundColor: t.palette.contrast_100,
})
}
} else if (variant === 'outline') {
@ -308,12 +309,6 @@ export function Button({
return {
baseStyles,
hoverStyles,
focusStyles: [
...hoverStyles,
{
outline: 0,
} as ViewStyle,
],
}
}, [t, variant, color, size, shape, disabled])
@ -376,10 +371,8 @@ export function Button({
a.flex_row,
a.align_center,
a.justify_center,
a.justify_center,
flattenedBaseStyles,
...(state.hovered || state.pressed ? hoverStyles : []),
...(state.focused ? focusStyles : []),
flatten(style),
]}
onPressIn={onPressIn}
@ -398,7 +391,7 @@ export function Button({
]}>
<LinearGradient
colors={
state.hovered || state.pressed || state.focused
state.hovered || state.pressed
? gradientHoverColors
: gradientColors
}
@ -527,7 +520,14 @@ export function ButtonText({children, style, ...rest}: ButtonTextProps) {
const textStyles = useSharedButtonTextStyles()
return (
<Text {...rest} style={[a.font_bold, a.text_center, textStyles, style]}>
<Text
{...rest}
style={normalizeTextStyles([
a.font_bold,
a.text_center,
textStyles,
style,
])}>
{children}
</Text>
)

View file

@ -99,7 +99,7 @@ export function Outer({
style={[
web(a.fixed),
a.inset_0,
{opacity: 0.5, backgroundColor: t.palette.black},
{opacity: 0.8, backgroundColor: t.palette.black},
]}
/>
)}

View file

@ -0,0 +1,27 @@
import React from 'react'
import LinearGradient from 'react-native-linear-gradient'
import {atoms as a, tokens} from '#/alf'
export function GradientFill({
gradient,
}: {
gradient:
| typeof tokens.gradients.sky
| typeof tokens.gradients.midnight
| typeof tokens.gradients.sunrise
| typeof tokens.gradients.sunset
| typeof tokens.gradients.bonfire
| typeof tokens.gradients.summer
| typeof tokens.gradients.nordic
}) {
return (
<LinearGradient
colors={gradient.values.map(c => c[1])}
locations={gradient.values.map(c => c[0])}
start={{x: 0, y: 0}}
end={{x: 1, y: 1}}
style={[a.absolute, a.inset_0]}
/>
)
}

View file

@ -0,0 +1,182 @@
import React from 'react'
import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {AppBskyLabelerDefs} from '@atproto/api'
import {getLabelingServiceTitle} from '#/lib/moderation'
import {Link as InternalLink, LinkProps} from '#/components/Link'
import {Text} from '#/components/Typography'
import {useLabelerInfoQuery} from '#/state/queries/labeler'
import {atoms as a, useTheme, ViewStyleProp} from '#/alf'
import {RichText} from '#/components/RichText'
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '../icons/Chevron'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {sanitizeHandle} from '#/lib/strings/handles'
import {pluralize} from '#/lib/strings/helpers'
type LabelingServiceProps = {
labeler: AppBskyLabelerDefs.LabelerViewDetailed
}
export function Outer({
children,
style,
}: React.PropsWithChildren<ViewStyleProp>) {
return (
<View
style={[
a.flex_row,
a.gap_md,
a.w_full,
a.p_lg,
a.pr_md,
a.overflow_hidden,
style,
]}>
{children}
</View>
)
}
export function Avatar({avatar}: {avatar?: string}) {
return <UserAvatar type="labeler" size={40} avatar={avatar} />
}
export function Title({value}: {value: string}) {
return <Text style={[a.text_md, a.font_bold]}>{value}</Text>
}
export function Description({value, handle}: {value?: string; handle: string}) {
return value ? (
<Text numberOfLines={2}>
<RichText value={value} style={[]} />
</Text>
) : (
<Text>
<Trans>By {sanitizeHandle(handle, '@')}</Trans>
</Text>
)
}
export function LikeCount({count}: {count: number}) {
const t = useTheme()
return (
<Text
style={[
a.mt_sm,
a.text_sm,
t.atoms.text_contrast_medium,
{fontWeight: '500'},
]}>
<Trans>
Liked by {count} {pluralize(count, 'user')}
</Trans>
</Text>
)
}
export function Content({children}: React.PropsWithChildren<{}>) {
const t = useTheme()
return (
<View
style={[
a.flex_1,
a.flex_row,
a.gap_md,
a.align_center,
a.justify_between,
]}>
<View style={[a.gap_xs, a.flex_1]}>{children}</View>
<ChevronRight size="md" style={[a.z_10, t.atoms.text_contrast_low]} />
</View>
)
}
/**
* The canonical view for a labeling service. Use this or compose your own.
*/
export function Default({
labeler,
style,
}: LabelingServiceProps & ViewStyleProp) {
return (
<Outer style={style}>
<Avatar />
<Content>
<Title
value={getLabelingServiceTitle({
displayName: labeler.creator.displayName,
handle: labeler.creator.handle,
})}
/>
<Description
value={labeler.creator.description}
handle={labeler.creator.handle}
/>
{labeler.likeCount ? <LikeCount count={labeler.likeCount} /> : null}
</Content>
</Outer>
)
}
export function Link({
children,
labeler,
}: LabelingServiceProps & Pick<LinkProps, 'children'>) {
const {_} = useLingui()
return (
<InternalLink
to={{
screen: 'Profile',
params: {
name: labeler.creator.handle,
},
}}
label={_(
msg`View the labeling service provided by @${labeler.creator.handle}`,
)}>
{children}
</InternalLink>
)
}
// TODO not finished yet
export function DefaultSkeleton() {
return (
<View>
<Text>Loading</Text>
</View>
)
}
export function Loader({
did,
loading: LoadingComponent = DefaultSkeleton,
error: ErrorComponent,
component: Component,
}: {
did: string
loading?: React.ComponentType<{}>
error?: React.ComponentType<{error: string}>
component: React.ComponentType<{
labeler: AppBskyLabelerDefs.LabelerViewDetailed
}>
}) {
const {isLoading, data, error} = useLabelerInfoQuery({did})
return isLoading ? (
LoadingComponent ? (
<LoadingComponent />
) : null
) : error || !data ? (
ErrorComponent ? (
<ErrorComponent error={error?.message || 'Unknown error'} />
) : null
) : (
<Component labeler={data} />
)
}

View file

@ -0,0 +1,109 @@
import React from 'react'
import {View} from 'react-native'
import {AppBskyFeedGetLikes as GetLikes} from '@atproto/api'
import {Trans} from '@lingui/macro'
import {logger} from '#/logger'
import {List} from '#/view/com/util/List'
import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard'
import {useResolveUriQuery} from '#/state/queries/resolve-uri'
import {useLikedByQuery} from '#/state/queries/post-liked-by'
import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
import {ListFooter} from '#/components/Lists'
import {atoms as a, useTheme} from '#/alf'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
export function LikedByList({uri}: {uri: string}) {
const t = useTheme()
const [isPTRing, setIsPTRing] = React.useState(false)
const {
data: resolvedUri,
error: resolveError,
isFetching: isFetchingResolvedUri,
} = useResolveUriQuery(uri)
const {
data,
isFetching,
isFetched,
isRefetching,
hasNextPage,
fetchNextPage,
isError,
error: likedByError,
refetch,
} = useLikedByQuery(resolvedUri?.uri)
const likes = React.useMemo(() => {
if (data?.pages) {
return data.pages.flatMap(page => page.likes)
}
return []
}, [data])
const initialNumToRender = useInitialNumToRender()
const error = resolveError || likedByError
const onRefresh = React.useCallback(async () => {
setIsPTRing(true)
try {
await refetch()
} catch (err) {
logger.error('Failed to refresh likes', {message: err})
}
setIsPTRing(false)
}, [refetch, setIsPTRing])
const onEndReached = React.useCallback(async () => {
if (isFetching || !hasNextPage || isError) return
try {
await fetchNextPage()
} catch (err) {
logger.error('Failed to load more likes', {message: err})
}
}, [isFetching, hasNextPage, isError, fetchNextPage])
const renderItem = React.useCallback(({item}: {item: GetLikes.Like}) => {
return (
<ProfileCardWithFollowBtn key={item.actor.did} profile={item.actor} />
)
}, [])
if (isFetchingResolvedUri || !isFetched) {
return (
<View style={[a.w_full, a.align_center, a.p_lg]}>
<Loader size="xl" />
</View>
)
}
return likes.length ? (
<List
data={likes}
keyExtractor={item => item.actor.did}
refreshing={isPTRing}
onRefresh={onRefresh}
onEndReached={onEndReached}
onEndReachedThreshold={3}
renderItem={renderItem}
initialNumToRender={initialNumToRender}
ListFooterComponent={() => (
<ListFooter
isFetching={isFetching && !isRefetching}
isError={isError}
error={error ? error.toString() : undefined}
onRetry={fetchNextPage}
/>
)}
/>
) : (
<View style={[a.p_lg]}>
<View style={[a.p_lg, a.rounded_sm, t.atoms.bg_contrast_25]}>
<Text style={[a.text_md, a.leading_snug]}>
<Trans>
Nobody has liked this yet. Maybe you should be the first!
</Trans>
</Text>
</View>
</View>
)
}

View file

@ -0,0 +1,131 @@
import React, {useMemo, useCallback} from 'react'
import {ActivityIndicator, FlatList, View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {AppBskyFeedGetLikes as GetLikes} from '@atproto/api'
import {useResolveUriQuery} from '#/state/queries/resolve-uri'
import {useLikedByQuery} from '#/state/queries/post-liked-by'
import {cleanError} from '#/lib/strings/errors'
import {logger} from '#/logger'
import {atoms as a, useTheme} from '#/alf'
import {Text} from '#/components/Typography'
import * as Dialog from '#/components/Dialog'
import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard'
import {Loader} from '#/components/Loader'
interface LikesDialogProps {
control: Dialog.DialogOuterProps['control']
uri: string
}
export function LikesDialog(props: LikesDialogProps) {
return (
<Dialog.Outer control={props.control}>
<Dialog.Handle />
<LikesDialogInner {...props} />
</Dialog.Outer>
)
}
export function LikesDialogInner({control, uri}: LikesDialogProps) {
const {_} = useLingui()
const t = useTheme()
const {
data: resolvedUri,
error: resolveError,
isFetched: hasFetchedResolvedUri,
} = useResolveUriQuery(uri)
const {
data,
isFetching: isFetchingLikedBy,
isFetched: hasFetchedLikedBy,
isFetchingNextPage,
hasNextPage,
fetchNextPage,
isError,
error: likedByError,
} = useLikedByQuery(resolvedUri?.uri)
const isLoading = !hasFetchedResolvedUri || !hasFetchedLikedBy
const likes = useMemo(() => {
if (data?.pages) {
return data.pages.flatMap(page => page.likes)
}
return []
}, [data])
const onEndReached = useCallback(async () => {
if (isFetchingLikedBy || !hasNextPage || isError) return
try {
await fetchNextPage()
} catch (err) {
logger.error('Failed to load more likes', {message: err})
}
}, [isFetchingLikedBy, hasNextPage, isError, fetchNextPage])
const renderItem = useCallback(
({item}: {item: GetLikes.Like}) => {
return (
<ProfileCardWithFollowBtn
key={item.actor.did}
profile={item.actor}
onPress={() => control.close()}
/>
)
},
[control],
)
return (
<Dialog.Inner label={_(msg`Users that have liked this content or profile`)}>
<Text style={[a.text_2xl, a.font_bold, a.leading_tight, a.pb_lg]}>
<Trans>Liked by</Trans>
</Text>
{isLoading ? (
<View style={{minHeight: 300}}>
<Loader size="xl" />
</View>
) : resolveError || likedByError || !data ? (
<ErrorMessage message={cleanError(resolveError || likedByError)} />
) : likes.length === 0 ? (
<View style={[t.atoms.bg_contrast_50, a.px_md, a.py_xl, a.rounded_md]}>
<Text style={[a.text_center]}>
<Trans>
Nobody has liked this yet. Maybe you should be the first!
</Trans>
</Text>
</View>
) : (
<FlatList
data={likes}
keyExtractor={item => item.actor.did}
onEndReached={onEndReached}
renderItem={renderItem}
initialNumToRender={15}
ListFooterComponent={
<ListFooterComponent isFetching={isFetchingNextPage} />
}
/>
)}
<Dialog.Close />
</Dialog.Inner>
)
}
function ListFooterComponent({isFetching}: {isFetching: boolean}) {
if (isFetching) {
return (
<View style={a.pt_lg}>
<ActivityIndicator />
</View>
)
}
return null
}

View file

@ -251,7 +251,7 @@ export function InlineLink({
onIn: onPressIn,
onOut: onPressOut,
} = useInteractionState()
const flattenedStyle = flatten(style)
const flattenedStyle = flatten(style) || {}
return (
<Text

View file

@ -33,7 +33,7 @@ export function ListFooter({
a.border_t,
a.pb_lg,
t.atoms.border_contrast_low,
{height: 100},
{height: 180},
]}>
{isFetching ? (
<Loader size="xl" />

View file

@ -223,7 +223,7 @@ export function Item({children, label, onPress, ...rest}: ItemProps) {
style={flatten([
a.flex_row,
a.align_center,
a.gap_sm,
a.gap_lg,
a.py_sm,
a.rounded_xs,
{minHeight: 32, paddingHorizontal: 10},

View file

@ -0,0 +1,183 @@
import React from 'react'
import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {AppBskyLabelerDefs} from '@atproto/api'
import {useReportOptions, ReportOption} from '#/lib/moderation/useReportOptions'
import {DMCA_LINK} from '#/components/ReportDialog/const'
import {Link} from '#/components/Link'
export {useDialogControl as useReportDialogControl} from '#/components/Dialog'
import {atoms as a, useTheme} from '#/alf'
import {Text} from '#/components/Typography'
import {
Button,
ButtonIcon,
ButtonText,
useButtonContext,
} from '#/components/Button'
import {Divider} from '#/components/Divider'
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
import {SquareArrowTopRight_Stroke2_Corner0_Rounded as SquareArrowTopRight} from '#/components/icons/SquareArrowTopRight'
import {ReportDialogProps} from './types'
export function SelectReportOptionView({
...props
}: ReportDialogProps & {
labelers: AppBskyLabelerDefs.LabelerViewDetailed[]
onSelectReportOption: (reportOption: ReportOption) => void
}) {
const t = useTheme()
const {_} = useLingui()
const allReportOptions = useReportOptions()
const reportOptions = allReportOptions[props.params.type]
const i18n = React.useMemo(() => {
let title = _(msg`Report this content`)
let description = _(msg`Why should this content be reviewed?`)
if (props.params.type === 'account') {
title = _(msg`Report this user`)
description = _(msg`Why should this user be reviewed?`)
} else if (props.params.type === 'post') {
title = _(msg`Report this post`)
description = _(msg`Why should this post be reviewed?`)
} else if (props.params.type === 'list') {
title = _(msg`Report this list`)
description = _(msg`Why should this list be reviewed?`)
} else if (props.params.type === 'feedgen') {
title = _(msg`Report this feed`)
description = _(msg`Why should this feed be reviewed?`)
}
return {
title,
description,
}
}, [_, props.params.type])
return (
<View style={[a.gap_lg]}>
<View style={[a.justify_center, a.gap_sm]}>
<Text style={[a.text_2xl, a.font_bold]}>{i18n.title}</Text>
<Text style={[a.text_md, t.atoms.text_contrast_medium]}>
{i18n.description}
</Text>
</View>
<Divider />
<View style={[a.gap_sm, {marginHorizontal: a.p_md.padding * -1}]}>
{reportOptions.map(reportOption => {
return (
<Button
key={reportOption.reason}
label={_(msg`Create report for ${reportOption.title}`)}
onPress={() => props.onSelectReportOption(reportOption)}>
<ReportOptionButton
title={reportOption.title}
description={reportOption.description}
/>
</Button>
)
})}
{(props.params.type === 'post' || props.params.type === 'account') && (
<View style={[a.pt_md, a.px_md]}>
<View
style={[
a.flex_row,
a.align_center,
a.justify_between,
a.gap_lg,
a.p_md,
a.pl_lg,
a.rounded_md,
t.atoms.bg_contrast_900,
]}>
<Text
style={[
a.flex_1,
t.atoms.text_inverted,
a.italic,
a.leading_snug,
]}>
<Trans>Need to report a copyright violation?</Trans>
</Text>
<Link
to={DMCA_LINK}
label={_(msg`View details for reporting a copyright violation`)}
size="small"
variant="solid"
color="secondary">
<ButtonText>
<Trans>View details</Trans>
</ButtonText>
<ButtonIcon position="right" icon={SquareArrowTopRight} />
</Link>
</View>
</View>
)}
</View>
</View>
)
}
function ReportOptionButton({
title,
description,
}: {
title: string
description: string
}) {
const t = useTheme()
const {hovered, pressed} = useButtonContext()
const interacted = hovered || pressed
const styles = React.useMemo(() => {
return {
interacted: {
backgroundColor: t.palette.contrast_50,
},
}
}, [t])
return (
<View
style={[
a.w_full,
a.flex_row,
a.align_center,
a.justify_between,
a.p_md,
a.rounded_md,
{paddingRight: 70},
interacted && styles.interacted,
]}>
<View style={[a.flex_1, a.gap_xs]}>
<Text style={[a.text_md, a.font_bold, t.atoms.text_contrast_medium]}>
{title}
</Text>
<Text style={[a.leading_tight, {maxWidth: 400}]}>{description}</Text>
</View>
<View
style={[
a.absolute,
a.inset_0,
a.justify_center,
a.pr_md,
{left: 'auto'},
]}>
<ChevronRight
size="md"
fill={
hovered ? t.palette.primary_500 : t.atoms.text_contrast_low.color
}
/>
</View>
</View>
)
}

View file

@ -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 {AppBskyLabelerDefs} from '@atproto/api'
import {getLabelingServiceTitle} from '#/lib/moderation'
import {ReportOption} from '#/lib/moderation/useReportOptions'
import {atoms as a, useTheme, native} from '#/alf'
import {Text} from '#/components/Typography'
import * as Dialog from '#/components/Dialog'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft} from '#/components/icons/Chevron'
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
import * as Toggle from '#/components/forms/Toggle'
import {CharProgress} from '#/view/com/composer/char-progress/CharProgress'
import {Loader} from '#/components/Loader'
import * as Toast from '#/view/com/util/Toast'
import {ReportDialogProps} from './types'
import {getAgent} from '#/state/session'
export function SubmitView({
params,
labelers,
selectedReportOption,
goBack,
onSubmitComplete,
}: ReportDialogProps & {
labelers: AppBskyLabelerDefs.LabelerViewDetailed[]
selectedReportOption: ReportOption
goBack: () => void
onSubmitComplete: () => void
}) {
const t = useTheme()
const {_} = useLingui()
const [details, setDetails] = React.useState<string>('')
const [submitting, setSubmitting] = React.useState<boolean>(false)
const [selectedServices, setSelectedServices] = React.useState<string[]>(
labelers?.map(labeler => labeler.creator.did) || [],
)
const [error, setError] = React.useState('')
const submit = React.useCallback(async () => {
setSubmitting(true)
setError('')
const $type =
params.type === 'account'
? 'com.atproto.admin.defs#repoRef'
: 'com.atproto.repo.strongRef'
const report = {
reasonType: selectedReportOption.reason,
subject: {
$type,
...params,
},
reason: details,
}
const results = await Promise.all(
selectedServices.map(did =>
getAgent()
.withProxy('atproto_labeler', did)
.createModerationReport(report)
.then(
_ => true,
_ => false,
),
),
)
setSubmitting(false)
if (results.includes(true)) {
Toast.show(_(msg`Thank you. Your report has been sent.`))
onSubmitComplete()
} else {
setError(
_(
msg`There was an issue sending your report. Please check your internet connection.`,
),
)
}
}, [
_,
params,
details,
selectedReportOption,
selectedServices,
onSubmitComplete,
setError,
])
return (
<View style={[a.gap_2xl]}>
<Button
size="small"
variant="solid"
color="secondary"
shape="round"
label={_(msg`Go back to previous step`)}
onPress={goBack}>
<ButtonIcon icon={ChevronLeft} />
</Button>
<View
style={[
a.w_full,
a.flex_row,
a.align_center,
a.justify_between,
a.gap_lg,
a.p_md,
a.rounded_md,
a.border,
t.atoms.border_contrast_low,
]}>
<View style={[a.flex_1, a.gap_xs]}>
<Text style={[a.text_md, a.font_bold]}>
{selectedReportOption.title}
</Text>
<Text style={[a.leading_tight, {maxWidth: 400}]}>
{selectedReportOption.description}
</Text>
</View>
<Check size="md" style={[a.pr_sm, t.atoms.text_contrast_low]} />
</View>
<View style={[a.gap_md]}>
<Text style={[t.atoms.text_contrast_medium]}>
<Trans>Select the moderation service(s) to report to</Trans>
</Text>
<Toggle.Group
label="Select mod services"
values={selectedServices}
onChange={setSelectedServices}>
<View style={[a.flex_row, a.gap_md, a.flex_wrap]}>
{labelers.map(labeler => {
const title = getLabelingServiceTitle({
displayName: labeler.creator.displayName,
handle: labeler.creator.handle,
})
return (
<Toggle.Item
key={labeler.creator.did}
name={labeler.creator.did}
label={title}>
<LabelerToggle title={title} />
</Toggle.Item>
)
})}
</View>
</Toggle.Group>
</View>
<View style={[a.gap_md]}>
<Text style={[t.atoms.text_contrast_medium]}>
<Trans>Optionally provide additional information below:</Trans>
</Text>
<View style={[a.relative, a.w_full]}>
<Dialog.Input
multiline
value={details}
onChangeText={setDetails}
label="Text field"
style={{paddingRight: 60}}
numberOfLines={6}
/>
<View
style={[
a.absolute,
a.flex_row,
a.align_center,
a.pr_md,
a.pb_sm,
{
bottom: 0,
right: 0,
},
]}>
<CharProgress count={details?.length || 0} />
</View>
</View>
</View>
<View style={[a.flex_row, a.align_center, a.justify_end, a.gap_lg]}>
{!selectedServices.length ||
(error && (
<Text
style={[
a.flex_1,
a.italic,
a.leading_snug,
t.atoms.text_contrast_medium,
]}>
{error ? (
error
) : (
<Trans>You must select at least one labeler for a report</Trans>
)}
</Text>
))}
<Button
size="large"
variant="solid"
color="negative"
label={_(msg`Send report`)}
onPress={submit}
disabled={!selectedServices.length}>
<ButtonText>
<Trans>Send report</Trans>
</ButtonText>
{submitting && <ButtonIcon icon={Loader} />}
</Button>
</View>
</View>
)
}
function LabelerToggle({title}: {title: string}) {
const t = useTheme()
const ctx = Toggle.useItemContext()
return (
<View
style={[
a.flex_row,
a.align_center,
a.gap_md,
a.p_md,
a.pr_lg,
a.rounded_sm,
a.overflow_hidden,
t.atoms.bg_contrast_25,
ctx.selected && [t.atoms.bg_contrast_50],
]}>
<Toggle.Checkbox />
<View
style={[
a.flex_row,
a.align_center,
a.justify_between,
a.gap_lg,
a.z_10,
]}>
<Text
style={[
native({marginTop: 2}),
t.atoms.text_contrast_medium,
ctx.selected && t.atoms.text,
]}>
{title}
</Text>
</View>
</View>
)
}

View file

@ -0,0 +1 @@
export const DMCA_LINK = 'https://bsky.social/about/support/copyright'

View file

@ -0,0 +1,73 @@
import React from 'react'
import {View, Pressable} from 'react-native'
import {Trans} from '@lingui/macro'
import {useMyLabelersQuery} from '#/state/queries/preferences'
import {ReportOption} from '#/lib/moderation/useReportOptions'
export {useDialogControl as useReportDialogControl} from '#/components/Dialog'
import {atoms as a} from '#/alf'
import {Loader} from '#/components/Loader'
import * as Dialog from '#/components/Dialog'
import {Text} from '#/components/Typography'
import {ReportDialogProps} from './types'
import {SelectReportOptionView} from './SelectReportOptionView'
import {SubmitView} from './SubmitView'
import {useDelayedLoading} from '#/components/hooks/useDelayedLoading'
export function ReportDialog(props: ReportDialogProps) {
return (
<Dialog.Outer control={props.control}>
<Dialog.Handle />
<ReportDialogInner {...props} />
</Dialog.Outer>
)
}
function ReportDialogInner(props: ReportDialogProps) {
const {
isLoading: isLabelerLoading,
data: labelers,
error,
} = useMyLabelersQuery()
const isLoading = useDelayedLoading(500, isLabelerLoading)
const [selectedReportOption, setSelectedReportOption] = React.useState<
ReportOption | undefined
>()
return (
<Dialog.ScrollableInner label="Report Dialog">
{isLoading ? (
<View style={[a.align_center, {height: 100}]}>
<Loader size="xl" />
{/* Here to capture focus for a hot sec to prevent flash */}
<Pressable accessible={false} />
</View>
) : error || !labelers ? (
<View>
<Text style={[a.text_md]}>
<Trans>Something went wrong, please try again.</Trans>
</Text>
</View>
) : selectedReportOption ? (
<SubmitView
{...props}
labelers={labelers}
selectedReportOption={selectedReportOption}
goBack={() => setSelectedReportOption(undefined)}
onSubmitComplete={() => props.control.close()}
/>
) : (
<SelectReportOptionView
{...props}
labelers={labelers}
onSelectReportOption={setSelectedReportOption}
/>
)}
<Dialog.Close />
</Dialog.ScrollableInner>
)
}

View file

@ -0,0 +1,15 @@
import * as Dialog from '#/components/Dialog'
export type ReportDialogProps = {
control: Dialog.DialogOuterProps['control']
params:
| {
type: 'post' | 'list' | 'feedgen' | 'other'
uri: string
cid: string
}
| {
type: 'account'
did: string
}
}

View file

@ -59,7 +59,7 @@ export function TagMenu({
const displayTag = '#' + tag
const isMuted = Boolean(
(preferences?.mutedWords?.find(
(preferences?.moderationPrefs.mutedWords?.find(
m => m.value === tag && m.targets.includes('tag'),
) ??
optimisticUpsert?.find(

View file

@ -50,7 +50,7 @@ export function TagMenu({
const {mutateAsync: removeMutedWord, variables: optimisticRemove} =
useRemoveMutedWordMutation()
const isMuted = Boolean(
(preferences?.mutedWords?.find(
(preferences?.moderationPrefs.mutedWords?.find(
m => m.value === tag && m.targets.includes('tag'),
) ??
optimisticUpsert?.find(

View file

@ -1,5 +1,10 @@
import React from 'react'
import {Text as RNText, TextStyle, TextProps as RNTextProps} from 'react-native'
import {
Text as RNText,
StyleProp,
TextStyle,
TextProps as RNTextProps,
} from 'react-native'
import {UITextView} from 'react-native-ui-text-view'
import {useTheme, atoms, web, flatten} from '#/alf'
@ -34,7 +39,7 @@ export function leading<
* If the `lineHeight` value is > 2, we assume it's an absolute value and
* returns it as-is.
*/
function normalizeTextStyles(styles: TextStyle[]) {
export function normalizeTextStyles(styles: StyleProp<TextStyle>) {
const s = flatten(styles)
// should always be defined on these components
const fontSize = s.fontSize || atoms.text_md.fontSize

View file

@ -18,7 +18,7 @@ export function useGlobalDialogsControlContext() {
export function Provider({children}: React.PropsWithChildren<{}>) {
const mutedWordsDialogControl = Dialog.useDialogControl()
const ctx = React.useMemo(
const ctx = React.useMemo<ControlsContext>(
() => ({mutedWordsDialogControl}),
[mutedWordsDialogControl],
)

View file

@ -233,8 +233,8 @@ function MutedWordsInner({}: {control: Dialog.DialogOuterProps['control']}) {
</Trans>
</Text>
</View>
) : preferences.mutedWords.length ? (
[...preferences.mutedWords]
) : preferences.moderationPrefs.mutedWords.length ? (
[...preferences.moderationPrefs.mutedWords]
.reverse()
.map((word, i) => (
<MutedWordRow

View file

@ -2,7 +2,14 @@ import React from 'react'
import {Pressable, View, ViewStyle} from 'react-native'
import {HITSLOP_10} from 'lib/constants'
import {useTheme, atoms as a, web, native, flatten, ViewStyleProp} from '#/alf'
import {
useTheme,
atoms as a,
native,
flatten,
ViewStyleProp,
TextStyleProp,
} from '#/alf'
import {Text} from '#/components/Typography'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {CheckThick_Stroke2_Corner0_Rounded as Checkmark} from '#/components/icons/Check'
@ -220,20 +227,17 @@ export function Item({
onPressOut={onPressOut}
onFocus={onFocus}
onBlur={onBlur}
style={[
a.flex_row,
a.align_center,
a.gap_sm,
focused ? web({outline: 'none'}) : {},
flatten(style),
]}>
style={[a.flex_row, a.align_center, a.gap_sm, flatten(style)]}>
{typeof children === 'function' ? children(state) : children}
</Pressable>
</ItemContext.Provider>
)
}
export function Label({children}: React.PropsWithChildren<{}>) {
export function Label({
children,
style,
}: React.PropsWithChildren<TextStyleProp>) {
const t = useTheme()
const {disabled} = useItemContext()
return (
@ -242,11 +246,14 @@ export function Label({children}: React.PropsWithChildren<{}>) {
a.font_bold,
{
userSelect: 'none',
color: disabled ? t.palette.contrast_400 : t.palette.contrast_600,
color: disabled
? t.atoms.text_contrast_low.color
: t.atoms.text_contrast_high.color,
},
native({
paddingTop: 3,
}),
flatten(style),
]}>
{children}
</Text>
@ -257,7 +264,6 @@ export function Label({children}: React.PropsWithChildren<{}>) {
export function createSharedToggleStyles({
theme: t,
hovered,
focused,
selected,
disabled,
isInvalid,
@ -280,7 +286,7 @@ export function createSharedToggleStyles({
borderColor: t.palette.primary_500,
})
if (hovered || focused) {
if (hovered) {
baseHover.push({
backgroundColor:
t.name === 'light' ? t.palette.primary_100 : t.palette.primary_800,
@ -289,7 +295,7 @@ export function createSharedToggleStyles({
})
}
} else {
if (hovered || focused) {
if (hovered) {
baseHover.push({
backgroundColor:
t.name === 'light' ? t.palette.contrast_50 : t.palette.contrast_100,
@ -306,7 +312,7 @@ export function createSharedToggleStyles({
t.name === 'light' ? t.palette.negative_300 : t.palette.negative_800,
})
if (hovered || focused) {
if (hovered) {
baseHover.push({
backgroundColor:
t.name === 'light' ? t.palette.negative_25 : t.palette.negative_900,
@ -353,7 +359,7 @@ export function Checkbox() {
width: 20,
},
baseStyles,
hovered || focused ? baseHoverStyles : {},
hovered ? baseHoverStyles : {},
]}>
{selected ? <Checkmark size="xs" fill={t.palette.primary_500} /> : null}
</View>
@ -385,7 +391,7 @@ export function Switch() {
width: 30,
},
baseStyles,
hovered || focused ? baseHoverStyles : {},
hovered ? baseHoverStyles : {},
]}>
<View
style={[
@ -437,7 +443,7 @@ export function Radio() {
width: 20,
},
baseStyles,
hovered || focused ? baseHoverStyles : {},
hovered ? baseHoverStyles : {},
]}>
{selected ? (
<View

View file

@ -8,7 +8,9 @@ import * as Toggle from '#/components/forms/Toggle'
export type ItemProps = Omit<Toggle.ItemProps, 'style' | 'role' | 'children'> &
AccessibilityProps &
React.PropsWithChildren<{testID?: string}>
React.PropsWithChildren<{
testID?: string
}>
export type GroupProps = Omit<Toggle.GroupProps, 'style' | 'type'> & {
multiple?: boolean
@ -101,12 +103,12 @@ function ButtonInner({children}: React.PropsWithChildren<{}>) {
native({
paddingBottom: 10,
}),
a.px_sm,
a.px_md,
t.atoms.bg,
t.atoms.border_contrast_low,
baseStyles,
activeStyles,
(state.hovered || state.focused || state.pressed) && hoverStyles,
(state.hovered || state.pressed) && hoverStyles,
]}>
{typeof children === 'string' ? (
<Text

View file

@ -0,0 +1,15 @@
import React from 'react'
export function useDelayedLoading(delay: number, initialState: boolean = true) {
const [isLoading, setIsLoading] = React.useState(initialState)
React.useEffect(() => {
let timeout: NodeJS.Timeout
// on initial load, show a loading spinner for a hot sec to prevent flash
if (isLoading) timeout = setTimeout(() => setIsLoading(false), delay)
return () => timeout && clearTimeout(timeout)
}, [isLoading, delay])
return isLoading
}

View file

@ -0,0 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE'
export const ArrowTriangleBottom_Stroke2_Corner1_Rounded = createSinglePathSVG({
path: 'M4.213 6.886c-.673-1.35.334-2.889 1.806-2.889H17.98c1.472 0 2.479 1.539 1.806 2.89l-5.982 11.997c-.74 1.484-2.87 1.484-3.61 0L4.213 6.886Z',
})

View file

@ -0,0 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE'
export const Bars3_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M3 5a1 1 0 0 0 0 2h18a1 1 0 1 0 0-2H3Zm-1 7a1 1 0 0 1 1-1h18a1 1 0 1 1 0 2H3a1 1 0 0 1-1-1Zm0 6a1 1 0 0 1 1-1h18a1 1 0 1 1 0 2H3a1 1 0 0 1-1-1Z',
})

View file

@ -7,3 +7,11 @@ export const ChevronLeft_Stroke2_Corner0_Rounded = createSinglePathSVG({
export const ChevronRight_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M8.293 3.293a1 1 0 0 1 1.414 0l8 8a1 1 0 0 1 0 1.414l-8 8a1 1 0 0 1-1.414-1.414L15.586 12 8.293 4.707a1 1 0 0 1 0-1.414Z',
})
export const ChevronTop_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M12 6a1 1 0 0 1 .707.293l8 8a1 1 0 0 1-1.414 1.414L12 8.414l-7.293 7.293a1 1 0 0 1-1.414-1.414l8-8A1 1 0 0 1 12 6Z',
})
export const ChevronBottom_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M3.293 8.293a1 1 0 0 1 1.414 0L12 15.586l7.293-7.293a1 1 0 1 1 1.414 1.414l-8 8a1 1 0 0 1-1.414 0l-8-8a1 1 0 0 1 0-1.414Z',
})

View file

@ -0,0 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE'
export const CircleBanSign_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M12 4a8 8 0 0 0-6.32 12.906L16.906 5.68A7.962 7.962 0 0 0 12 4Zm6.32 3.094L7.094 18.32A8 8 0 0 0 18.32 7.094ZM2 12C2 6.477 6.477 2 12 2a9.972 9.972 0 0 1 7.071 2.929A9.972 9.972 0 0 1 22 12c0 5.523-4.477 10-10 10a9.972 9.972 0 0 1-7.071-2.929A9.972 9.972 0 0 1 2 12Z',
})

View file

@ -0,0 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE'
export const SettingsGear2_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M11.1 2a1 1 0 0 0-.832.445L8.851 4.57 6.6 4.05a1 1 0 0 0-.932.268l-1.35 1.35a1 1 0 0 0-.267.932l.52 2.251-2.126 1.417A1 1 0 0 0 2 11.1v1.8a1 1 0 0 0 .445.832l2.125 1.417-.52 2.251a1 1 0 0 0 .268.932l1.35 1.35a1 1 0 0 0 .932.267l2.251-.52 1.417 2.126A1 1 0 0 0 11.1 22h1.8a1 1 0 0 0 .832-.445l1.417-2.125 2.251.52a1 1 0 0 0 .932-.268l1.35-1.35a1 1 0 0 0 .267-.932l-.52-2.251 2.126-1.417A1 1 0 0 0 22 12.9v-1.8a1 1 0 0 0-.445-.832L19.43 8.851l.52-2.251a1 1 0 0 0-.268-.932l-1.35-1.35a1 1 0 0 0-.932-.267l-2.251.52-1.417-2.126A1 1 0 0 0 12.9 2h-1.8Zm-.968 4.255L11.635 4h.73l1.503 2.255a1 1 0 0 0 1.057.42l2.385-.551.566.566-.55 2.385a1 1 0 0 0 .42 1.057L20 11.635v.73l-2.255 1.503a1 1 0 0 0-.42 1.057l.551 2.385-.566.566-2.385-.55a1 1 0 0 0-1.057.42L12.365 20h-.73l-1.503-2.255a1 1 0 0 0-1.057-.42l-2.385.551-.566-.566.55-2.385a1 1 0 0 0-.42-1.057L4 12.365v-.73l2.255-1.503a1 1 0 0 0 .42-1.057L6.123 6.69l.566-.566 2.385.55a1 1 0 0 0 1.057-.42ZM8 12a4 4 0 1 1 8 0 4 4 0 0 1-8 0Zm4-2a2 2 0 1 0 0 4 2 2 0 0 0 0-4Z',
})

View file

@ -0,0 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE'
export const RaisingHande4Finger_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M10.25 4a.75.75 0 0 0-.75.75V11a1 1 0 1 1-2 0V6.75a.75.75 0 0 0-1.5 0V14a6 6 0 0 0 12 0V9a2 2 0 0 0-2 2v1.5a1 1 0 0 1-.684.949l-.628.21A2.469 2.469 0 0 0 13 16a1 1 0 1 1-2 0 4.469 4.469 0 0 1 3-4.22V11c0-.703.181-1.364.5-1.938V5.75a.75.75 0 0 0-1.5 0V9a1 1 0 1 1-2 0V4.75a.75.75 0 0 0-.75-.75Zm2.316-.733A2.75 2.75 0 0 1 16.5 5.75v1.54c.463-.187.97-.29 1.5-.29h1a1 1 0 0 1 1 1v6a8 8 0 1 1-16 0V6.75a2.75 2.75 0 0 1 3.571-2.625 2.751 2.751 0 0 1 4.995-.858Z',
})

View file

@ -0,0 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE'
export const Shield_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M11.675 2.054a1 1 0 0 1 .65 0l8 2.75A1 1 0 0 1 21 5.75v6.162c0 2.807-1.149 4.83-2.813 6.405-1.572 1.488-3.632 2.6-5.555 3.636l-.157.085a1 1 0 0 1-.95 0l-.157-.085c-1.923-1.037-3.983-2.148-5.556-3.636C4.15 16.742 3 14.719 3 11.912V5.75a1 1 0 0 1 .675-.946l8-2.75ZM5 6.464v5.448c0 2.166.851 3.687 2.188 4.952 1.276 1.209 2.964 2.158 4.812 3.157 1.848-1 3.536-1.948 4.813-3.157C18.148 15.6 19 14.078 19 11.912V6.464l-7-2.407-7 2.407Z',
})

View file

@ -0,0 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE'
export const SquareArrowTopRight_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M14 5a1 1 0 1 1 0-2h6a1 1 0 0 1 1 1v6a1 1 0 1 1-2 0V6.414l-7.293 7.293a1 1 0 0 1-1.414-1.414L17.586 5H14ZM3 6a1 1 0 0 1 1-1h5a1 1 0 0 1 0 2H5v12h12v-4a1 1 0 1 1 2 0v5a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V6Z',
})

View file

@ -0,0 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE'
export const SquareBehindSquare4_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M8 8V3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1h-5v5a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1h5Zm1 8a1 1 0 0 1-1-1v-5H4v10h10v-4H9Z',
})

View file

@ -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',
},
})

View file

@ -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>
)
}

View file

@ -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} />
)
}

View file

@ -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>
</>
)
}

View file

@ -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>
)
}

View file

@ -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>
)
}

View file

@ -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} />
</>
)
}

View file

@ -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,
},
})

View file

@ -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} />
</>
)
}

View file

@ -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>
)
}