Merge branch '3p-moderators' of github.com:bluesky-social/social-app into 3p-moderators

This commit is contained in:
Paul Frazee
2024-02-14 21:51:02 -08:00
15 changed files with 905 additions and 432 deletions
+29 -18
View File
@@ -48,25 +48,30 @@ export type VariantProps = {
shape?: ButtonShape
}
export type ButtonProps = React.PropsWithChildren<
Pick<PressableProps, 'disabled' | 'onPress' | 'testID'> &
AccessibilityProps &
VariantProps & {
testID?: string
label: string
style?: StyleProp<ViewStyle>
}
>
export type ButtonState = {
hovered: boolean
focused: boolean
pressed: boolean
disabled: boolean
}
export type ButtonProps = Pick<
PressableProps,
'disabled' | 'onPress' | 'testID'
> &
AccessibilityProps &
VariantProps & {
testID?: string
label: string
style?: StyleProp<ViewStyle>
children:
| React.ReactNode
| string
| ((state: VariantProps & ButtonState) => React.ReactNode | string)
}
export type ButtonTextProps = TextProps & VariantProps & {disabled?: boolean}
const Context = React.createContext<
VariantProps & {
hovered: boolean
focused: boolean
pressed: boolean
disabled: boolean
}
>({
const Context = React.createContext<VariantProps & ButtonState>({
hovered: false,
focused: false,
pressed: false,
@@ -402,6 +407,8 @@ export function Button({
<Context.Provider value={context}>
{typeof children === 'string' ? (
<ButtonText>{children}</ButtonText>
) : typeof children === 'function' ? (
children(context)
) : (
children
)}
@@ -524,9 +531,11 @@ export function ButtonText({children, style, ...rest}: ButtonTextProps) {
export function ButtonIcon({
icon: Comp,
position,
size: iconSize,
}: {
icon: React.ComponentType<SVGIconProps>
position?: 'left' | 'right'
size?: SVGIconProps['size']
}) {
const {size, disabled} = useButtonContext()
const textStyles = useSharedButtonTextStyles()
@@ -542,7 +551,9 @@ export function ButtonIcon({
},
]}>
<Comp
size={size === 'large' ? 'md' : size === 'tiny' ? 'xs' : 'sm'}
size={
iconSize ?? (size === 'large' ? 'md' : size === 'tiny' ? 'xs' : 'sm')
}
style={[{color: textStyles.color, pointerEvents: 'none'}]}
/>
</View>
+27 -22
View File
@@ -10,6 +10,8 @@ import {Portal} from '#/components/Portal'
import {DialogOuterProps, DialogInnerProps} from '#/components/Dialog/types'
import {Context} from '#/components/Dialog/context'
import {Button, ButtonIcon} from '#/components/Button'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
export {useDialogControl, useDialogContext} from '#/components/Dialog/context'
export * from '#/components/Dialog/types'
@@ -171,25 +173,28 @@ export function Handle() {
return null
}
/**
* TODO(eric) unused rn
*/
// export function Close() {
// const {_} = useLingui()
// const t = useTheme()
// const {close} = useDialogContext()
// return (
// <View
// style={[
// a.absolute,
// a.z_10,
// {
// top: a.pt_lg.paddingTop,
// right: a.pr_lg.paddingRight,
// },
// ]}>
// <Button onPress={close} label={_(msg`Close active dialog`)}>
// </Button>
// </View>
// )
// }
export function Close() {
const {_} = useLingui()
const {close} = React.useContext(Context)
return (
<View
style={[
a.absolute,
a.z_10,
{
top: a.pt_md.paddingTop,
right: a.pr_md.paddingRight,
},
]}>
<Button
size="small"
variant="ghost"
color="primary"
shape="round"
onPress={close}
label={_(msg`Close active dialog`)}>
<ButtonIcon icon={X} size="md" />
</Button>
</View>
)
}
-133
View File
@@ -1,133 +0,0 @@
import React from 'react'
import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import LinearGradient from 'react-native-linear-gradient'
import {AppBskyModerationDefs} from '@atproto/api'
import {atoms as a, useTheme, tokens, web} from '#/alf'
import {Link, useLinkContext} from '#/components/Link'
import {Text} from '#/components/Typography'
import {RichText} from '#/components/RichText'
import {RaisingHande4Finger_Stroke2_Corner0_Rounded as RaisingHand} from '#/components/icons/RaisingHand'
import {useModServiceInfoQuery} from '#/state/queries/modservice'
type ModerationServiceCardProps = {
modservice: AppBskyModerationDefs.ModServiceViewDetailed
}
export function ModerationServiceCard({
modservice,
}: ModerationServiceCardProps) {
const {_} = useLingui()
return (
<Link
to={{
screen: 'ProfileModservice',
params: {
name: modservice.creator.handle,
},
}}
label={_(
msg`View the moderation service provided by @${modservice.creator.handle}`,
)}>
<Inner modservice={modservice} />
</Link>
)
}
function Inner({modservice}: ModerationServiceCardProps) {
const t = useTheme()
const {hovered, pressed, focused} = useLinkContext()
return (
<View
style={[
a.flex_row,
a.align_center,
a.justify_between,
a.gap_sm,
a.w_full,
a.rounded_sm,
a.pt_md,
a.pb_sm,
a.pl_lg,
a.pr_sm,
a.overflow_hidden,
web({
transition: 'transform 0.2s cubic-bezier(.02,.73,.27,.99)',
}),
{
transform: [{scale: pressed || hovered || focused ? 0.992 : 1}],
},
]}>
<LinearGradient
colors={tokens.gradients.midnight.values.map(c => c[1])}
locations={tokens.gradients.midnight.values.map(c => c[0])}
start={{x: 0, y: 0}}
end={{x: 1, y: 1}}
style={[a.absolute, a.inset_0]}
/>
<View style={[a.z_10]}>
<Text
style={[a.text_md, a.font_bold, a.pb_2xs, {color: t.palette.white}]}>
{/* TODO */}
{modservice.displayName || 'Mod service'}
</Text>
{modservice.description ? (
<RichText
value={modservice.description}
style={[{color: t.palette.white}]}
/>
) : (
<Text>
<Trans>
Moderation service managed by @{modservice.creator.handle}
</Trans>
</Text>
)}
</View>
<RaisingHand size="xl" style={[a.z_10]} fill={t.palette.white} />
</View>
)
}
export function ModerationServiceCardSkeleton() {
return (
<View>
<Text>Loading</Text>
</View>
)
}
export function Loader({
did,
loading: LoadingComponent = ModerationServiceCardSkeleton,
error: ErrorComponent,
component: Component,
}: {
did: string
loading?: React.ComponentType<{}>
error?: React.ComponentType<{error: string}>
component: React.ComponentType<{
modservice: AppBskyModerationDefs.ModServiceViewDetailed
}>
}) {
const {isLoading, data, error} = useModServiceInfoQuery({did})
return isLoading ? (
LoadingComponent ? (
<LoadingComponent />
) : null
) : error || !data ? (
ErrorComponent ? (
<ErrorComponent error={error?.message || 'Unknown error'} />
) : null
) : (
<Component modservice={data} />
)
}
@@ -0,0 +1,74 @@
import React from 'react'
import {View} from 'react-native'
import {Trans} from '@lingui/macro'
import {atoms as a, useTheme, ViewStyleProp, flatten} from '#/alf'
import {Text} from '#/components/Typography'
import {RichText} from '#/components/RichText'
import {RaisingHande4Finger_Stroke2_Corner0_Rounded as RaisingHand} from '#/components/icons/RaisingHand'
import {UserAvatar} from '#/view/com/util/UserAvatar'
export function Outer({
children,
style,
}: React.PropsWithChildren<ViewStyleProp>) {
const t = useTheme()
return (
<View
style={[
a.flex_row,
a.align_center,
a.gap_sm,
a.w_full,
a.rounded_sm,
a.pt_md,
a.pb_md,
a.pl_md,
a.pr_md,
a.overflow_hidden,
a.border,
t.atoms.border_contrast_low,
t.atoms.bg_contrast_25,
flatten(style),
]}>
<View style={[a.flex_row, a.align_center, a.w_full, a.gap_md]}>
{children}
</View>
</View>
)
}
export function Avatar({avatar}: {avatar?: string}) {
return <UserAvatar type="list" size={40} avatar={avatar} />
}
export function Content({
title,
description,
handle,
}: {
title: string
description?: string
handle: string
}) {
const t = useTheme()
return (
<View style={[a.flex_1, a.flex_row, a.align_center, a.justify_between]}>
<View>
<Text style={[a.text_md, a.font_bold, a.pb_2xs]}>{title}</Text>
{description ? (
<RichText value={description} style={[]} />
) : (
<Text>
<Trans>Moderation service managed by @{handle}</Trans>
</Text>
)}
</View>
<RaisingHand size="xl" style={[a.z_10]} fill={t.palette.primary_500} />
</View>
)
}
@@ -0,0 +1,73 @@
import React from 'react'
import {View} from 'react-native'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {AppBskyModerationDefs} from '@atproto/api'
import {Link as InternalLink, LinkProps} from '#/components/Link'
import {Text} from '#/components/Typography'
import {useModServiceInfoQuery} from '#/state/queries/modservice'
export * as Card from '#/components/ModerationServiceCard/Card'
type ModerationServiceProps = {
modservice: AppBskyModerationDefs.ModServiceViewDetailed
}
export function Link({
children,
modservice,
}: ModerationServiceProps & Pick<LinkProps, 'children'>) {
const {_} = useLingui()
return (
<InternalLink
to={{
screen: 'ProfileModservice',
params: {
name: modservice.creator.handle,
},
}}
label={_(
msg`View the moderation service provided by @${modservice.creator.handle}`,
)}>
{children}
</InternalLink>
)
}
export function ModerationServiceCardSkeleton() {
return (
<View>
<Text>Loading</Text>
</View>
)
}
export function Loader({
did,
loading: LoadingComponent = ModerationServiceCardSkeleton,
error: ErrorComponent,
component: Component,
}: {
did: string
loading?: React.ComponentType<{}>
error?: React.ComponentType<{error: string}>
component: React.ComponentType<{
modservice: AppBskyModerationDefs.ModServiceViewDetailed
}>
}) {
const {isLoading, data, error} = useModServiceInfoQuery({did})
return isLoading ? (
LoadingComponent ? (
<LoadingComponent />
) : null
) : error || !data ? (
ErrorComponent ? (
<ErrorComponent error={error?.message || 'Unknown error'} />
) : null
) : (
<Component modservice={data} />
)
}
+136 -39
View File
@@ -2,7 +2,7 @@ import React from 'react'
import {View, Dimensions} from 'react-native'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {LABEL_GROUPS, LabelGroupDefinition} from '@atproto/api'
import {AppBskyModerationDefs, LabelGroupDefinition} from '@atproto/api'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {atoms as a, useTheme, tokens, native} from '#/alf'
@@ -25,10 +25,16 @@ import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
import * as Toggle from '#/components/forms/Toggle'
import {GradientFill} from '#/components/GradientFill'
import * as TextField from '#/components/forms/TextField'
import {CharProgress} from '#/view/com/composer/char-progress/CharProgress'
import {Loader} from '#/components/Loader'
import * as Toast from '#/view/com/util/Toast'
import {usePreferencesQuery} from '#/state/queries/preferences'
import {useModServicesDetailedInfoQuery} from '#/state/queries/modservice'
import {
getLabelGroupsFromLabels,
getModerationServiceTitle,
useConfigurableLabelGroups,
} from '#/lib/moderation'
export type ReportDialogProps =
| {
@@ -41,7 +47,11 @@ export type ReportDialogProps =
did: string
}
function LabelGroupButton({labelGroup}: {labelGroup: string}) {
function LabelGroupButton({
labelGroup,
}: {
labelGroup: LabelGroupDefinition['id']
}) {
const t = useTheme()
const {hovered, focused, pressed} = useButtonContext()
const labelGroupStrings = useLabelGroupStrings()
@@ -143,22 +153,89 @@ function ModServiceToggle({title}: {title: string}) {
)
}
function SubmitViewLoader({
children,
}: {
children: (props: {
labelers: AppBskyModerationDefs.ModServiceViewDetailed[]
labelGroupToModServiceMap: Record<LabelGroupDefinition['id'], string[]>
}) => React.ReactNode
}) {
const {
isLoading: isPreferencesLoading,
error: preferencesError,
data: preferences,
} = usePreferencesQuery()
const {
isLoading: isModServicesLoading,
data: modservices,
error: modservicesError,
} = useModServicesDetailedInfoQuery({
dids: preferences ? preferences.moderationOpts.mods.map(m => m.did) : [],
})
const labelGroupToModServiceMap = React.useMemo(() => {
if (!modservices) return {}
const groups: Partial<
Record<
LabelGroupDefinition['id'],
AppBskyModerationDefs.ModServiceViewDetailed[]
>
> = {}
for (const modservice of modservices) {
const labelGroups = getLabelGroupsFromLabels(
modservice.policies.labelValues,
)
for (const group of labelGroups) {
const g = (groups[group.id] = groups[group.id] || [])
g.push(modservice)
}
}
return groups
}, [modservices])
const isLoading = isPreferencesLoading || isModServicesLoading
const error = preferencesError || modservicesError
return isLoading ? (
<View style={[{height: 500}]}>
<Loader />
</View>
) : error || !(preferences && modservices) ? null : ( // TODO
children({
labelers: modservices,
// TODO mismatched types
// @ts-ignore
labelGroupToModServiceMap,
})
)
}
function SubmitView({
selectedLabelGroup,
goBack,
onSubmitComplete,
labelGroupToModServiceMap,
}: {
selectedLabelGroup: string
selectedLabelGroup: LabelGroupDefinition['id']
goBack: () => void
onSubmitComplete: () => void
labelers: AppBskyModerationDefs.ModServiceViewDetailed[]
labelGroupToModServiceMap: Record<
LabelGroupDefinition['id'],
AppBskyModerationDefs.ModServiceViewDetailed[]
>
}) {
const t = useTheme()
const {_} = useLingui()
const labelGroupStrings = useLabelGroupStrings()
const groupInfoStrings = labelGroupStrings[selectedLabelGroup]
const [selectedServices, setSelectedServices] = React.useState<string[]>([])
const [details, setDetails] = React.useState<string>('')
const [submitting, setSubmitting] = React.useState<boolean>(false)
const [selectedServices, setSelectedServices] = React.useState<string[]>([])
const supportedLabelers = labelGroupToModServiceMap[selectedLabelGroup]
const submit = React.useCallback(async () => {
setSubmitting(true)
@@ -208,22 +285,35 @@ function SubmitView({
Select the moderation service(s) to report to
</Text>
<Toggle.Group
label="Select mod services"
values={selectedServices}
onChange={setSelectedServices}>
<View style={[a.flex_row, a.gap_sm, a.flex_wrap]}>
<Toggle.Item name="bluesky" label="Bluesky">
<ModServiceToggle title="Bluesky" />
</Toggle.Item>
<Toggle.Item name="contraption" label="The Contraption">
<ModServiceToggle title="The Contraption" />
</Toggle.Item>
<Toggle.Item name="safety" label="Safety Corp">
<ModServiceToggle title="Safety Corp" />
</Toggle.Item>
{supportedLabelers ? (
<Toggle.Group
label="Select mod services"
values={selectedServices}
onChange={setSelectedServices}>
<View style={[a.flex_row, a.gap_sm, a.flex_wrap]}>
{supportedLabelers.map(labeler => {
const title = getModerationServiceTitle({
displayName: labeler.creator.displayName,
handle: labeler.creator.handle,
})
return (
<Toggle.Item
key={labeler.creator.did}
name={labeler.creator.did}
label={title}>
<ModServiceToggle title={title} />
</Toggle.Item>
)
})}
</View>
</Toggle.Group>
) : (
<View style={[a.p_lg, a.rounded_sm, t.atoms.bg_contrast_25]}>
<Text style={[a.italic, t.atoms.text_contrast_medium]}>
None of your subscribed labelers support this content type.
</Text>
</View>
</Toggle.Group>
)}
</View>
<View style={[a.gap_md]}>
<Text style={[t.atoms.text_contrast_medium]}>
@@ -231,13 +321,14 @@ function SubmitView({
</Text>
<View style={[a.relative, a.w_full]}>
<TextField.Input
<Dialog.Input
multiline
value={details}
onChangeText={setDetails}
label="Text field"
style={{paddingRight: 60}}
numberOfLines={6}
disabled={!supportedLabelers}
/>
<View
@@ -260,10 +351,11 @@ function SubmitView({
<View style={[a.align_end]}>
<Button
size="large"
variant="gradient"
color="gradient_midnight"
variant="solid"
color="primary"
label={_(msg`Submit`)}
onPress={submit}>
onPress={submit}
disabled={!supportedLabelers}>
<ButtonText>Submit</ButtonText>
{submitting && <ButtonIcon icon={Loader} />}
</Button>
@@ -284,7 +376,9 @@ export function ReportDialog({
const {_} = useLingui()
const insets = useSafeAreaInsets()
const control = Dialog.useDialogControl()
const [selectedLabelGroup, setSelectedLabelGroup] = React.useState<string>('')
const [selectedLabelGroup, setSelectedLabelGroup] = React.useState<
LabelGroupDefinition['id'] | undefined
>()
const labelGroupStrings = useLabelGroupStrings()
// REQUIRED CLEANUP
@@ -305,11 +399,7 @@ export function ReportDialog({
}
}, [_, params.type])
const groups = React.useMemo<
[keyof typeof LABEL_GROUPS, LabelGroupDefinition][]
>(() => {
return Object.entries(LABEL_GROUPS).filter(([, def]) => def.configurable)
}, [])
const groups = useConfigurableLabelGroups()
return (
<Dialog.Outer
@@ -325,11 +415,18 @@ export function ReportDialog({
<Dialog.ScrollableInner label="Report Dialog">
{selectedLabelGroup ? (
<SubmitView
selectedLabelGroup={selectedLabelGroup}
goBack={() => setSelectedLabelGroup('')}
onSubmitComplete={control.close}
/>
<SubmitViewLoader>
{props => (
// TODO same types mismatch
// @ts-ignore
<SubmitView
{...props}
selectedLabelGroup={selectedLabelGroup}
goBack={() => setSelectedLabelGroup(undefined)}
onSubmitComplete={control.close}
/>
)}
</SubmitViewLoader>
) : (
<View style={[a.gap_lg]}>
<View style={[a.justify_center, a.gap_sm]}>
@@ -342,14 +439,14 @@ export function ReportDialog({
<Divider />
<View style={[a.gap_sm, {marginHorizontal: a.p_md.padding * -1}]}>
{groups.map(([name, def]) => {
const groupStrings = labelGroupStrings[name]
{groups.map(def => {
const groupStrings = labelGroupStrings[def.id]
return (
<Button
key={def.id}
label={_(msg`Create report for ${groupStrings.name}`)}
onPress={() => setSelectedLabelGroup(name)}>
<LabelGroupButton labelGroup={name} />
onPress={() => setSelectedLabelGroup(def.id)}>
<LabelGroupButton labelGroup={def.id} />
</Button>
)
})}
+1
View File
@@ -149,6 +149,7 @@ export type InputProps = Omit<TextInputProps, 'value' | 'onChangeText'> & {
value: string
onChangeText: (value: string) => void
isInvalid?: boolean
disabled?: boolean
}
export function createInput(Component: typeof TextInput) {
+5
View File
@@ -0,0 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE'
export const TimesLarge_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M4.293 4.293a1 1 0 0 1 1.414 0L12 10.586l6.293-6.293a1 1 0 1 1 1.414 1.414L13.414 12l6.293 6.293a1 1 0 0 1-1.414 1.414L12 13.414l-6.293 6.293a1 1 0 0 1-1.414-1.414L10.586 12 4.293 5.707a1 1 0 0 1 0-1.414Z',
})
+23
View File
@@ -1,4 +1,7 @@
import React from 'react'
import {ModerationCause, LABEL_GROUPS, LabelGroupDefinition} from '@atproto/api'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
export function getModerationCauseKey(cause: ModerationCause): string {
const source =
@@ -27,3 +30,23 @@ export function getLabelGroupsFromLabels(labels: string[]) {
return Array.from(groups)
}
export function getConfigurableLabelGroups() {
return Object.values(LABEL_GROUPS).filter(group => group.configurable)
}
export function useConfigurableLabelGroups() {
return React.useMemo(() => getConfigurableLabelGroups(), [])
}
export function getModerationServiceTitle({
displayName,
handle,
}: {
displayName?: string
handle: string
}) {
return displayName
? sanitizeDisplayName(displayName)
: sanitizeHandle(handle, '@')
}
+155
View File
@@ -0,0 +1,155 @@
import React from 'react'
import {View} from 'react-native'
import {AppBskyModerationDefs} from '@atproto/api'
import {LabelGroupDefinition} from '@atproto/api'
import {Trans} from '@lingui/macro'
import {useTheme, atoms as a} from '#/alf'
import {Text} from '#/components/Typography'
import * as Toggle from '#/components/forms/Toggle'
import {useLabelGroupStrings} from '#/lib/moderation/useLabelGroupStrings'
import * as Dialog from '#/components/Dialog'
import * as ModerationServiceCard from '#/components/ModerationServiceCard'
import {getModerationServiceTitle} from '#/lib/moderation'
import {UsePreferencesQueryResponse} from '#/state/queries/preferences'
import {useModServiceLabelGroupEnableMutation} from '#/state/queries/modservice'
function LabelerToggle({
labelGroup,
labeler,
preferences,
}: {
labelGroup: LabelGroupDefinition['id']
labeler: AppBskyModerationDefs.ModServiceViewDetailed
preferences: UsePreferencesQueryResponse
}) {
const t = useTheme()
const {mutateAsync, variables, reset} =
useModServiceLabelGroupEnableMutation()
const modservicePreferences = preferences.moderationOpts.mods.find(
({did}) => did === labeler.creator.did,
)
const enabled =
variables?.enabled ??
!modservicePreferences?.disabledLabelGroups?.includes(labelGroup)
const title = getModerationServiceTitle({
displayName: labeler.creator.displayName,
handle: labeler.creator.handle,
})
const onToggleEnabled = React.useCallback(async () => {
try {
await mutateAsync({
// @ts-ignore TODO
did: modservicePreferences?.did,
group: labelGroup,
enabled: !enabled,
})
reset() // Important: clears query `variables`
} catch (e: any) {
// TODO
console.error(e)
}
}, [mutateAsync, enabled, modservicePreferences, labelGroup, reset])
return (
<Toggle.Item
name={labeler.creator.did}
label={title}
key={labeler.creator.did}
value={enabled}
onChange={onToggleEnabled}>
{ctx => (
<ModerationServiceCard.Card.Outer
style={[
...(ctx.focused || ctx.hovered ? [t.atoms.bg_contrast_50] : []),
ctx.selected && {
backgroundColor: t.palette.primary_50,
borderColor: t.palette.primary_100,
},
]}>
<ModerationServiceCard.Card.Avatar avatar={labeler.creator.avatar} />
<ModerationServiceCard.Card.Content
title={title}
description={labeler.description}
handle={labeler.creator.handle}
/>
</ModerationServiceCard.Card.Outer>
)}
</Toggle.Item>
)
}
export type SettingsDialogProps = {
labelGroup: LabelGroupDefinition['id']
modservices: AppBskyModerationDefs.ModServiceViewDetailed[]
}
export function SettingsDialog({
labelGroup,
modservices,
preferences,
}: SettingsDialogProps & {
preferences: UsePreferencesQueryResponse
}) {
const t = useTheme()
const labelGroupStrings = useLabelGroupStrings()
const groupInfoStrings = labelGroupStrings[labelGroup]
// this is mounted on native
if (!groupInfoStrings) return null
return (
<Dialog.ScrollableInner label="Configure moderation service settings">
<Dialog.Close />
<Text style={[a.text_xl, a.font_bold, a.pb_xl]}>
<Trans>Configure enabled labelers</Trans>
</Text>
<View
style={[
a.w_full,
a.flex_row,
a.align_center,
a.justify_between,
a.gap_lg,
a.p_md,
a.rounded_md,
a.mb_xl,
a.border,
t.atoms.border_contrast_low,
]}>
<View style={[a.flex_1, a.gap_xs]}>
<Text style={[a.text_md, a.font_bold]}>{groupInfoStrings.name}</Text>
<Text style={[a.leading_tight, {maxWidth: 400}]}>
{groupInfoStrings.description}
</Text>
</View>
</View>
<Text
style={[
a.leading_snug,
a.font_bold,
t.atoms.text_contrast_medium,
a.pb_lg,
]}>
<Trans>Select which labelers to use for this type of content:</Trans>
</Text>
<View style={[a.w_full, a.gap_md]}>
{modservices.map(modservice => {
return (
<LabelerToggle
key={modservice.creator.did}
labelGroup={labelGroup}
labeler={modservice}
preferences={preferences}
/>
)
})}
</View>
</Dialog.ScrollableInner>
)
}
+276 -211
View File
@@ -1,14 +1,10 @@
import React from 'react'
import {View} from 'react-native'
import {useFocusEffect} from '@react-navigation/native'
import {ComAtprotoLabelDefs} from '@atproto/api'
import {ComAtprotoLabelDefs, LabelPreference} from '@atproto/api'
import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {
LABEL_GROUPS,
LabelGroupDefinition,
DEFAULT_LABEL_GROUP_SETTINGS,
} from '@atproto/api'
import {LabelGroupDefinition, AppBskyModerationDefs} from '@atproto/api'
import {NativeStackScreenProps, CommonNavigatorParams} from '#/lib/routes/types'
import {CenteredView} from '#/view/com/util/Views'
@@ -22,12 +18,18 @@ import {
} from '#/state/queries/profile'
import {ScrollView} from '#/view/com/util/Views'
import {
UsePreferencesQueryResponse,
usePreferencesQuery,
useSetContentLabelMutation,
} from '#/state/queries/preferences'
import {useModServicesDetailedInfoQuery} from '#/state/queries/modservice'
import {useTheme, atoms as a, useBreakpoints} from '#/alf'
import {Divider} from '#/components/Divider'
import {CircleBanSign_Stroke2_Corner0_Rounded as CircleBanSign} from '#/components/icons/CircleBanSign'
import {Group3_Stroke2_Corner0_Rounded as Group} from '#/components/icons/Group'
import {Person_Stroke2_Corner0_Rounded as Person} from '#/components/icons/Person'
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
import {Text} from '#/components/Typography'
import * as Toggle from '#/components/forms/Toggle'
import * as ToggleButton from '#/components/forms/ToggleButton'
@@ -36,98 +38,71 @@ import {Loader} from '#/components/Loader'
import {useLabelGroupStrings} from '#/lib/moderation/useLabelGroupStrings'
import * as Dialog from '#/components/Dialog'
import {Button} from '#/components/Button'
import {
getLabelGroupsFromLabels,
getModerationServiceTitle,
useConfigurableLabelGroups,
} from '#/lib/moderation'
function ModSettingsToggleItem({name}: {name: string}) {
import {
SettingsDialog,
SettingsDialogProps,
} from '#/screens/Moderation/SettingsDialog'
export function ModerationScreen(
_props: NativeStackScreenProps<CommonNavigatorParams, 'Moderation'>,
) {
const t = useTheme()
const ctx = Toggle.useItemContext()
return (
<View
style={[
a.w_full,
a.flex_row,
a.justify_between,
a.align_center,
a.p_md,
a.rounded_sm,
t.atoms.bg_contrast_25,
{
backgroundColor: ctx.selected ? t.palette.primary_25 : undefined,
},
]}>
<Text>{name}</Text>
{ctx.selected && <Check />}
const {
isLoading: isPreferencesLoading,
// error: preferencesError,
data: preferences,
} = usePreferencesQuery()
return isPreferencesLoading ? (
<View style={[a.w_full, a.align_center]}>
<Loader size="xl" fill={t.atoms.text.color} />
</View>
)
) : preferences ? (
<ModerationScreenIntermediate preferences={preferences} />
) : // TODO
null
}
function ModSettingsDialog({
name,
onComplete,
function ModerationScreenIntermediate({
preferences,
}: {
name: string
onComplete: () => void
preferences: UsePreferencesQueryResponse
}) {
const t = useTheme()
const labelGroupStrings = useLabelGroupStrings()
const [selectedServices, setSelectedServices] = React.useState<string[]>([
'bluesky',
])
const {
isLoading: isModServicesLoading,
data: modservices,
// error: modservicesError,
} = useModServicesDetailedInfoQuery({
dids: preferences.moderationOpts.mods.map(m => m.did),
})
const save = React.useCallback(() => {
onComplete()
}, [onComplete])
return (
<Dialog.Inner label="Configure moderation service settings">
<Text style={[a.text_xl, a.font_bold, a.pb_sm]}>
Configure moderation services
</Text>
<Text
style={[
a.text_md,
a.leading_snug,
t.atoms.text_contrast_high,
a.pb_lg,
]}>
Select which moderation services' labels you'd like to use to filter
content matching {labelGroupStrings[name].name}.
</Text>
<Toggle.Group
values={selectedServices}
onChange={setSelectedServices}
label="Select one or more">
<View style={[a.w_full, a.gap_md]}>
<Toggle.Item name="bluesky" label="Bluesky">
<ModSettingsToggleItem name="Bluesky" />
</Toggle.Item>
<Toggle.Item name="contraption" label="Contraption">
<ModSettingsToggleItem name="Contraption" />
</Toggle.Item>
<Toggle.Item name="safety" label="Safety Corp">
<ModSettingsToggleItem name="Safety Corp" />
</Toggle.Item>
</View>
</Toggle.Group>
<View style={[a.flex_row, a.justify_end, a.pt_lg]}>
<Button
size="large"
variant="solid"
color="primary"
label="Save"
onPress={save}>
{selectedServices.length ? 'Save' : 'Ignore all'}
</Button>
</View>
</Dialog.Inner>
)
return isModServicesLoading ? (
<View style={[a.w_full, a.align_center]}>
<Loader size="xl" fill={t.atoms.text.color} />
</View>
) : modservices ? (
<ModerationScreenInner
preferences={preferences}
modservices={modservices}
/>
) : // TODO
null
}
export function ModerationScreen({}: NativeStackScreenProps<
CommonNavigatorParams,
'Moderation'
>) {
export function ModerationScreenInner({
preferences,
modservices,
}: {
preferences: UsePreferencesQueryResponse
modservices: AppBskyModerationDefs.ModServiceViewDetailed[]
}) {
const t = useTheme()
const {_} = useLingui()
const setMinimalShellMode = useSetMinimalShellMode()
@@ -135,8 +110,13 @@ export function ModerationScreen({}: NativeStackScreenProps<
const {gtMobile, gtTablet} = useBreakpoints()
const labelGroupStrings = useLabelGroupStrings()
const modSettingsDialogControl = Dialog.useDialogControl()
const [modSettingsDialogLabelGroup, setModSettingsDialogLabelGroup] =
React.useState<string>(() => Object.keys(LABEL_GROUPS)[0])
const [settingsDialogProps, setSettingsDialogProps] =
React.useState<SettingsDialogProps>({
// @ts-ignore
labelGroup: '',
modservices: [],
})
useFocusEffect(
React.useCallback(() => {
@@ -145,20 +125,40 @@ export function ModerationScreen({}: NativeStackScreenProps<
}, [screen, setMinimalShellMode]),
)
const groups = React.useMemo<
[keyof typeof LABEL_GROUPS, LabelGroupDefinition][]
const groups = useConfigurableLabelGroups()
const didToModServiceMap = React.useMemo<
Record<string, AppBskyModerationDefs.ModServiceViewDetailed>
>(() => {
return Object.entries(LABEL_GROUPS).filter(([, def]) => def.configurable)
}, [])
const labelOptions = {
hide: _(msg`Hide`),
warn: _(msg`Warn`),
show: _(msg`Show`),
}
return modservices.reduce((acc, modservice) => {
return {
...acc,
[modservice.creator.did]: modservice,
}
}, {})
}, [modservices])
const labelGroupToModServiceMap = React.useMemo(() => {
const groups: Partial<Record<LabelGroupDefinition['id'], string[]>> = {}
for (const modservice of modservices) {
const labelGroups = getLabelGroupsFromLabels(
modservice.policies.labelValues,
)
for (const group of labelGroups) {
const g = (groups[group.id] = groups[group.id] || [])
g.push(modservice.creator.did)
}
}
return groups
}, [modservices])
const openModSettingsDialog = React.useCallback(
({name}: {name: string}) => {
setModSettingsDialogLabelGroup(name)
({labelGroup, modservices}: Omit<SettingsDialogProps, 'onComplete'>) => {
setSettingsDialogProps({
labelGroup,
modservices,
})
modSettingsDialogControl.open()
},
[modSettingsDialogControl],
@@ -175,11 +175,7 @@ export function ModerationScreen({}: NativeStackScreenProps<
testID="moderationScreen">
<Dialog.Outer control={modSettingsDialogControl}>
<Dialog.Handle />
<ModSettingsDialog
name={modSettingsDialogLabelGroup}
onComplete={() => modSettingsDialogControl.close()}
/>
<SettingsDialog {...settingsDialogProps} preferences={preferences} />
</Dialog.Outer>
<ViewHeader title={_(msg`Moderation`)} showOnDesktop />
@@ -255,114 +251,21 @@ export function ModerationScreen({}: NativeStackScreenProps<
<Trans>Content filtering settings</Trans>
</Text>
{groups.map(([name, def], i) => {
const groupStrings = labelGroupStrings[name]
{groups.map((def, i) => {
const groupStrings = labelGroupStrings[def.id]
const modDids = labelGroupToModServiceMap[def.id] || []
const mods = modDids.map(did => didToModServiceMap[did])
return (
<React.Fragment key={def.id}>
{i !== 0 && <Divider />}
<View style={[a.pb_md]}>
<View
style={[
a.py_md,
a.flex_row,
a.justify_between,
a.gap_sm,
a.align_center,
]}>
<View style={[a.gap_xs, {width: '50%'}]}>
<Text
style={[
a.text_md,
a.font_bold,
t.atoms.text_contrast_medium,
]}>
{groupStrings.name}
</Text>
<Text style={[a.leading_tight, {maxWidth: 400}]}>
{groupStrings.description}
</Text>
</View>
<View>
<ToggleButton.Group
label={_(
msg`Configure content filtering setting for category: ${groupStrings.name.toLowerCase()}`,
)}
values={[DEFAULT_LABEL_GROUP_SETTINGS[name]]}
onChange={() => {}}>
<ToggleButton.Button
name="hide"
label={labelOptions.hide}>
{labelOptions.hide}
</ToggleButton.Button>
<ToggleButton.Button
name="warn"
label={labelOptions.warn}>
{labelOptions.warn}
</ToggleButton.Button>
<ToggleButton.Button
name="ignore"
label={labelOptions.show}>
{labelOptions.show}
</ToggleButton.Button>
</ToggleButton.Group>
</View>
</View>
<View style={[a.flex_row, a.align_start]}>
<Button
label={_(
msg`Configure moderation services for category: ${groupStrings.name.toLowerCase()}`,
)}
onPress={() =>
openModSettingsDialog({
name,
})
}>
<View style={[a.flex_row, a.align_start, a.gap_xs]}>
<View
style={[
a.py_sm,
a.px_md,
a.rounded_full,
t.atoms.bg_contrast_800,
]}>
<Text
style={[
a.text_xs,
a.font_bold,
t.atoms.text_inverted,
]}>
Bluesky
</Text>
</View>
<View
style={[
a.py_sm,
a.px_md,
a.rounded_full,
t.atoms.bg_contrast_25,
]}>
<Text style={[a.text_xs, a.font_bold]}>
Contraption
</Text>
</View>
<View
style={[
a.py_sm,
a.px_md,
a.rounded_full,
t.atoms.bg_contrast_25,
]}>
<Text style={[a.text_xs, a.font_bold]}>
Safety Corp
</Text>
</View>
</View>
</Button>
</View>
</View>
<LabelGroup
name={groupStrings.name}
description={groupStrings.description}
labelers={mods}
openModSettingsDialog={openModSettingsDialog}
preferences={preferences}
labelGroup={def.id}
/>
</React.Fragment>
)
})}
@@ -381,6 +284,168 @@ export function ModerationScreen({}: NativeStackScreenProps<
)
}
function LabelGroup({
labelGroup,
name,
description,
labelers: mods,
preferences,
openModSettingsDialog,
}: {
labelGroup: LabelGroupDefinition['id']
name: string
description: string
labelers: AppBskyModerationDefs.ModServiceViewDetailed[]
preferences: UsePreferencesQueryResponse
openModSettingsDialog: (props: SettingsDialogProps) => void
}) {
const t = useTheme()
const {_} = useLingui()
const {mutateAsync: setContentLabelPref, variables: optimisticContentLabel} =
useSetContentLabelMutation()
const onChangeVisibility = React.useCallback(
async (values: string[]) => {
try {
await setContentLabelPref({
labelGroup,
visibility: values[0] as LabelPreference,
})
} catch (e) {
console.error(e)
}
},
[labelGroup, setContentLabelPref],
)
const value =
optimisticContentLabel?.visibility ??
preferences.moderationOpts.labelGroups[labelGroup]
const labelOptions = {
hide: _(msg`Hide`),
warn: _(msg`Warn`),
show: _(msg`Show`),
}
return (
<View style={[]}>
<View
style={[
a.py_md,
a.flex_row,
a.justify_between,
a.gap_sm,
a.align_center,
]}>
<View style={[a.gap_xs, {width: '50%'}]}>
<Text style={[a.text_md, a.font_bold, t.atoms.text_contrast_medium]}>
{name}
</Text>
<Text style={[a.leading_tight, {maxWidth: 400}]}>{description}</Text>
</View>
<View>
<ToggleButton.Group
label={_(
msg`Configure content filtering setting for category: ${name.toLowerCase()}`,
)}
values={[value]}
onChange={onChangeVisibility}>
<ToggleButton.Button name="hide" label={labelOptions.hide}>
{labelOptions.hide}
</ToggleButton.Button>
<ToggleButton.Button name="warn" label={labelOptions.warn}>
{labelOptions.warn}
</ToggleButton.Button>
<ToggleButton.Button name="ignore" label={labelOptions.show}>
{labelOptions.show}
</ToggleButton.Button>
</ToggleButton.Group>
</View>
</View>
{!!mods.length && (
<View style={[a.flex_row, a.align_start, a.pb_md]}>
<Button
label={_(
msg`Configure moderation services for category: ${name.toLowerCase()}`,
)}
onPress={() =>
openModSettingsDialog({
labelGroup,
modservices: mods,
})
}
style={[a.flex_1]}>
{ctx => (
<View
style={[
a.w_full,
a.flex_row,
a.align_center,
a.gap_xs,
a.p_sm,
a.border,
a.rounded_sm,
t.atoms.border_contrast_low,
(ctx.hovered || ctx.focused) && t.atoms.bg_contrast_25,
]}>
{mods.map(mod => {
const modservicePreferences =
preferences.moderationOpts.mods.find(
({did}) => did === mod.creator.did,
)
const enabled =
!modservicePreferences?.disabledLabelGroups?.includes(
labelGroup,
)
return (
<View
key={mod.creator.did}
style={[
a.py_xs,
a.px_sm,
a.rounded_sm,
a.border,
t.atoms.border_contrast_low,
enabled && t.atoms.bg_contrast_800,
]}>
<Text
style={[
a.text_xs,
a.font_bold,
t.atoms.text_contrast_high,
enabled && t.atoms.text_inverted,
]}>
{getModerationServiceTitle({
displayName: mod.creator.displayName,
handle: mod.creator.handle,
})}
</Text>
</View>
)
})}
<Text
style={[
a.text_xs,
a.font_bold,
a.pl_sm,
a.italic,
t.atoms.text_contrast_low,
]}>
Configure
</Text>
</View>
)}
</Button>
</View>
)}
</View>
)
}
function PwiOptOut() {
const t = useTheme()
const {_} = useLingui()
+55 -4
View File
@@ -1,10 +1,19 @@
import {z} from 'zod'
import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query'
import {AppBskyModerationDefs} from '@atproto/api'
import {getAgent} from '#/state/session'
import {preferencesQueryKey} from '#/state/queries/preferences'
export const modServiceInfoQueryKey = (did: string) => ['mod-service-info', did]
export const modServicesInfoQueryKey = (dids: string[]) => [
'mod-services-info',
dids,
]
export const modServicesDetailedInfoQueryKey = (dids: string[]) => [
'mod-services-detailed-info',
dids,
]
export function useModServiceInfoQuery({did}: {did: string}) {
return useQuery({
@@ -16,6 +25,42 @@ export function useModServiceInfoQuery({did}: {did: string}) {
})
}
export function useModServicesInfoQuery({dids}: {dids: string[]}) {
return useQuery({
enabled: !!dids.length,
queryKey: modServicesInfoQueryKey(dids),
queryFn: async () => {
const res = await getAgent().app.bsky.moderation.getServices({dids})
return res.data.views
},
})
}
export function useModServicesDetailedInfoQuery({dids}: {dids: string[]}) {
return useQuery({
queryKey: modServicesDetailedInfoQueryKey(dids),
queryFn: async () => {
const views: AppBskyModerationDefs.ModServiceViewDetailed[] = []
await Promise.all(
dids.map(did => {
return getAgent()
.app.bsky.moderation.getService({did})
.then(res => {
views.push(res.data)
})
.catch(e => {
console.error(e)
return null
})
}),
)
return views
},
})
}
export function useModServiceSubscriptionMutation() {
const queryClient = useQueryClient()
@@ -52,9 +97,7 @@ export function useModServiceEnableMutation() {
enabled: z.boolean(),
}).parse({did, enabled})
await getAgent().setModServiceEnabled(did, enabled)
},
onSuccess() {
queryClient.invalidateQueries({
await queryClient.invalidateQueries({
queryKey: preferencesQueryKey,
})
},
@@ -65,7 +108,15 @@ export function useModServiceLabelGroupEnableMutation() {
const queryClient = useQueryClient()
return useMutation({
async mutationFn({did, group, enabled}: {did: string; group: string, enabled: boolean}) {
async mutationFn({
did,
group,
enabled,
}: {
did: string
group: string
enabled: boolean
}) {
// TODO
z.object({
did: z.string(),
+21
View File
@@ -4,6 +4,7 @@ import {
LabelPreference,
BskyFeedViewPreference,
ModerationOpts,
LabelGroupDefinition,
} from '@atproto/api'
import {track} from '#/lib/analytics/analytics'
@@ -124,6 +125,26 @@ export function usePreferencesSetContentLabelMutation() {
})
}
export function useSetContentLabelMutation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({
labelGroup,
visibility,
}: {
labelGroup: LabelGroupDefinition['id']
visibility: LabelPreference
}) => {
await getAgent().setContentLabelPref(labelGroup, visibility)
// triggers a refetch
await queryClient.invalidateQueries({
queryKey: preferencesQueryKey,
})
},
})
}
export function usePreferencesSetAdultContentMutation() {
const queryClient = useQueryClient()
+29 -5
View File
@@ -56,10 +56,10 @@ import {Shadow} from '#/state/cache/types'
import {useRequireAuth} from '#/state/session'
import {LabelInfo} from '../util/moderation/LabelInfo'
import {useProfileShadow} from 'state/cache/profile-shadow'
import {
Loader as ModServiceLoader,
ModerationServiceCard,
} from '#/components/ModerationServiceCard'
import * as ModerationServiceCard from '#/components/ModerationServiceCard'
import {getModerationServiceTitle} from '#/lib/moderation'
import {useTheme} from '#/alf'
let ProfileHeaderLoading = (_props: {}): React.ReactNode => {
const pal = usePalette('default')
@@ -96,6 +96,7 @@ let ProfileHeader = ({
hideBackButton = false,
isPlaceholderProfile,
}: Props): React.ReactNode => {
const t = useTheme()
const profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> =
useProfileShadow(profileUnshadowed)
const pal = usePalette('default')
@@ -629,7 +630,30 @@ let ProfileHeader = ({
<LabelInfo details={{did: profile.did}} labels={profile.labels} />
)}
<ModServiceLoader did={profile.did} component={ModerationServiceCard} />
<ModerationServiceCard.Loader
did={profile.did}
component={({modservice}) => (
<ModerationServiceCard.Link modservice={modservice}>
{ctx => (
<ModerationServiceCard.Card.Outer
style={[
...(ctx.focused || ctx.hovered
? [t.atoms.bg_contrast_50]
: []),
]}>
<ModerationServiceCard.Card.Content
title={getModerationServiceTitle({
displayName: modservice.creator.displayName,
handle: modservice.creator.handle,
})}
description={modservice.description}
handle={modservice.creator.handle}
/>
</ModerationServiceCard.Card.Outer>
)}
</ModerationServiceCard.Link>
)}
/>
</View>
{showSuggestedFollows && (
+1
View File
@@ -62,6 +62,7 @@ export function Forms() {
value={value}
onChangeText={setValue}
label="Text field"
disabled
/>
</View>